forked from eatvio/eatvio_app
Compare commits
63 Commits
post_creat
...
issue-1062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bbb08f2a2 | ||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
|
|
f8e06b6d7a | ||
|
|
866ea2fe3b | ||
| d73f68e24b | |||
|
|
61cded0081 | ||
|
|
df634fedd8 | ||
|
|
603a158cf2 | ||
| a1bae68307 | |||
|
|
2dce28b196 | ||
| ae4f9d2e9b | |||
|
|
a00aae36cf | ||
|
|
27e98bdb44 | ||
|
|
8ec6d5b799 | ||
|
|
077cfb3a67 | ||
| c4f5cae36d | |||
|
|
b040c6e020 | ||
|
|
745e77f948 | ||
|
|
c6ea1d414d | ||
|
|
10af279d87 | ||
| d0879e556f | |||
|
|
8f933118a3 | ||
| 7dc7eab774 | |||
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
| eeb261df0a | |||
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
| 184f0417a7 | |||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
| b679e90ba2 | |||
| 2474c5bea3 | |||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
| 191bb74aa4 |
@@ -8,8 +8,16 @@ jobs:
|
||||
build:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||
- name: Add Homebrew to PATH
|
||||
run: |
|
||||
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
run: |
|
||||
git --version
|
||||
fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
@@ -59,12 +67,16 @@ jobs:
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter --version
|
||||
flutter config --no-enable-swift-package-manager
|
||||
rm -rf ios/Podfile.lock
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
pod install
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
fastlane beta
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
|
||||
@@ -78,12 +78,7 @@
|
||||
<data android:host="eatvio.com" />
|
||||
<data android:host="www.eatvio.com" />
|
||||
|
||||
<!-- Development/Subdomains -->
|
||||
<data android:host="*.eatvio.ru" />
|
||||
<data android:host="*.eatvio.com" />
|
||||
|
||||
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
|
||||
<data android:pathPattern="/.*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
|
||||
@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
||||
return
|
||||
}
|
||||
|
||||
if (MainActivity.isInForeground) {
|
||||
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
||||
return
|
||||
}
|
||||
|
||||
// Data-only call pushes are delivered only to this service.
|
||||
// FlutterFirebaseMessagingService is removed, so the Dart listener does
|
||||
// not see them. Show the incoming UI here for both foreground and background.
|
||||
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
|
||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||
import com.huawei.hms.aaid.HmsInstanceId
|
||||
import com.huawei.hms.api.ConnectionResult
|
||||
import com.huawei.hms.api.HuaweiApiAvailability
|
||||
import com.huawei.hms.common.ApiException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -82,6 +85,20 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isHmsAvailable" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val available = try {
|
||||
HuaweiApiAvailability.getInstance()
|
||||
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
runOnUiThread {
|
||||
result.success(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
"getHuaweiToken" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
@@ -121,10 +138,30 @@ class MainActivity: FlutterActivity() {
|
||||
runOnUiThread {
|
||||
result.success(token)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
if (e.statusCode == 907135003) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
val message = e.message.orEmpty()
|
||||
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ object VideoCallPushBridge {
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
|
||||
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
||||
|
||||
@@ -44,6 +44,8 @@ post_install do |installer|
|
||||
'$(inherited)',
|
||||
## dart: PermissionGroup.camera
|
||||
'PERMISSION_CAMERA=1',
|
||||
## dart: PermissionGroup.microphone
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
## dart: PermissionGroup.photos
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_NOTIFICATIONS=1',
|
||||
|
||||
@@ -4,6 +4,7 @@ import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import WebRTC
|
||||
import YandexLoginSDK
|
||||
import flutter_callkit_incoming
|
||||
import flutter_foreground_task
|
||||
@@ -15,7 +16,11 @@ import flutter_foreground_task
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private let appUrlScheme = "eatvio"
|
||||
/// CallKit is only the ringing UI. After accept the in-app call continues,
|
||||
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
|
||||
private var resumeAudioAfterCallKitEnds = false
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -57,6 +62,27 @@ import flutter_foreground_task
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
let callAudioChannel = FlutterMethodChannel(
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activate":
|
||||
self.activateInAppCallAudioSession()
|
||||
result(nil)
|
||||
case "deactivate":
|
||||
self.deactivateInAppCallAudioSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return launched
|
||||
@@ -120,6 +146,35 @@ import flutter_foreground_task
|
||||
)
|
||||
}
|
||||
|
||||
/// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message,
|
||||
/// so the next call in this chat is not rejected as a duplicate UUID.
|
||||
private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? {
|
||||
guard let messageId, messageId > 0 else { return nil }
|
||||
let pattern =
|
||||
#"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(conversationUuid.startIndex..., in: conversationUuid)
|
||||
guard let match = regex.firstMatch(in: conversationUuid, range: range),
|
||||
let prefixRange = Range(match.range(at: 1), in: conversationUuid)
|
||||
else { return nil }
|
||||
let suffix = String(format: "%012llx", messageId)
|
||||
guard suffix.count == 12 else { return nil }
|
||||
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
|
||||
}
|
||||
|
||||
private func voipMessageId(_ value: Any?) -> UInt64? {
|
||||
if let number = value as? NSNumber {
|
||||
let parsed = number.uint64Value
|
||||
return parsed > 0 ? parsed : nil
|
||||
}
|
||||
if let text = value as? String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let parsed = UInt64(trimmed), parsed > 0 else { return nil }
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||
guard
|
||||
let actionUrl = actionUrl,
|
||||
@@ -343,13 +398,16 @@ import flutter_foreground_task
|
||||
return
|
||||
}
|
||||
|
||||
let nameCaller = (dict["nameCaller"] as? String)
|
||||
let rawNameCaller = (dict["nameCaller"] as? String)
|
||||
?? (dict["counterpartName"] as? String)
|
||||
?? (dict["counterpart_name"] as? String)
|
||||
?? (dict["caller_name"] as? String)
|
||||
?? (dict["callerName"] as? String)
|
||||
?? (dict["caller"] as? String)
|
||||
?? ""
|
||||
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Итвио"
|
||||
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let conversationUuid = (dict["conversation_uuid"] as? String)
|
||||
?? (dict["conversationUuid"] as? String)
|
||||
?? (dict["conversation_id"] as? String)
|
||||
@@ -358,26 +416,27 @@ import flutter_foreground_task
|
||||
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
|
||||
?? (dict["handle"] as? String)
|
||||
?? ""
|
||||
let callKitIdSource = [
|
||||
(key: "call_id", value: dict["call_id"] as? String),
|
||||
(key: "id", value: dict["id"] as? String),
|
||||
(key: "message_id", value: dict["message_id"] as? String),
|
||||
]
|
||||
.first { item in
|
||||
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return false
|
||||
}
|
||||
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
|
||||
}
|
||||
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
||||
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
|
||||
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parsedMessageId = voipMessageId(dict["message_id"])
|
||||
let id: String
|
||||
let callKitIdSourceName: String
|
||||
if let derived = derivedCallKitId(
|
||||
conversationUuid: trimmedConversationUuid,
|
||||
messageId: parsedMessageId
|
||||
) {
|
||||
id = derived
|
||||
callKitIdSourceName = "conversation_and_message"
|
||||
} else {
|
||||
id = UUID().uuidString
|
||||
callKitIdSourceName = "generated"
|
||||
}
|
||||
let isVideo = (dict["isVideo"] as? Bool)
|
||||
?? (dict["is_video"] as? Bool)
|
||||
?? true
|
||||
|
||||
var extra: [String: Any] = [:]
|
||||
if !conversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = conversationUuid
|
||||
if !trimmedConversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = trimmedConversationUuid
|
||||
}
|
||||
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
||||
?? dict["avatarUrl"] as? String
|
||||
@@ -409,9 +468,10 @@ import flutter_foreground_task
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
nameCaller: nameCaller,
|
||||
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.duration = 45000
|
||||
data.extra = extra as NSDictionary
|
||||
|
||||
NSLog(
|
||||
@@ -482,6 +542,8 @@ import flutter_foreground_task
|
||||
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
resumeAudioAfterCallKitEnds = true
|
||||
prepareCallAudioCategory()
|
||||
storePendingAcceptedCall(call)
|
||||
recordVoipDiagnostic(
|
||||
"callkit_accept_native",
|
||||
@@ -500,6 +562,7 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
@@ -509,9 +572,71 @@ import flutter_foreground_task
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidActivate(audioSession)
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||
guard resumeAudioAfterCallKitEnds else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.resumeAudioAfterCallKitEnds else { return }
|
||||
self.activateInAppCallAudioSession()
|
||||
}
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
func providerDidReset() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
}
|
||||
|
||||
private func prepareCallAudioCategory() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
let configuration = callAudioConfiguration()
|
||||
do {
|
||||
try rtcSession.setConfiguration(configuration)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func activateInAppCallAudioSession() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
|
||||
rtcSession.isAudioEnabled = true
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateInAppCallAudioSession() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.isAudioEnabled = false
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setActive(false)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func callAudioConfiguration() -> RTCAudioSessionConfiguration {
|
||||
let configuration = RTCAudioSessionConfiguration.webRTC()
|
||||
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
|
||||
configuration.categoryOptions = [
|
||||
.allowBluetooth,
|
||||
.defaultToSpeaker,
|
||||
]
|
||||
configuration.mode = AVAudioSession.Mode.voiceChat.rawValue
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<array>
|
||||
<string>applinks:eatvio.ru</string>
|
||||
<string>applinks:www.eatvio.ru</string>
|
||||
<string>applinks:eatvio.com</string>
|
||||
<string>applinks:www.eatvio.com</string>
|
||||
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io' as io;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/firebase_options.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Класс для инициализации приложения
|
||||
class AppInitializer {
|
||||
|
||||
/// Инициализация всех сервисов приложения
|
||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||
await _initializeEnvironment();
|
||||
@@ -42,25 +37,21 @@ class AppInitializer {
|
||||
|
||||
/// Инициализация Firebase
|
||||
static Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
VideoCallFcmHandler.registerBackgroundHandler();
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем работу
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Инициализация системы внедрения зависимостей
|
||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
||||
static Future<void> _initializeDependencyInjection({
|
||||
required AppBuildType buildType,
|
||||
}) async {
|
||||
await di.init(buildType: buildType);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
||||
|
||||
// Content viewing cubits
|
||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Rules for which errors are sent to crash reporting.
|
||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
||||
if (error == null) return false;
|
||||
if (error is String) return false;
|
||||
if (error is SocketException) return false;
|
||||
if (isHmsClientUnavailable(error)) return false;
|
||||
|
||||
if (error is DioException) {
|
||||
return _shouldReportDio(error);
|
||||
|
||||
@@ -3,59 +3,57 @@ import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final ErrorReporter _errorReporter;
|
||||
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||
|
||||
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
|
||||
FirebaseApi({
|
||||
required ErrorReporter errorReporter,
|
||||
NotificationPermissionRequester? notificationPermissionRequester,
|
||||
}) : _errorReporter = errorReporter,
|
||||
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||
|
||||
Future<String?> getToken() async {
|
||||
String? token;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
return credentials.token;
|
||||
}
|
||||
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
||||
try {
|
||||
await _firebaseMessaging.requestPermission().timeout(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||
try {
|
||||
await _notificationPermissionRequester.requestOnce(
|
||||
() => _firebaseMessaging.requestPermission(),
|
||||
);
|
||||
} catch (e) {
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -73,14 +71,17 @@ class FirebaseApi {
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -115,13 +116,13 @@ class FirebaseApi {
|
||||
Future<void> unregisterPushToken() async {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
||||
final token = (await getToken()) ?? '';
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token ?? '';
|
||||
if (token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/unregister',
|
||||
data: {
|
||||
'platform': platform,
|
||||
'platform': credentials.platform,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
@@ -181,30 +182,74 @@ class FirebaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String? token, String platform})> _resolvePushCredentials() async {
|
||||
if (Platform.isIOS) {
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
final huaweiFamily = await _isHuaweiDevice();
|
||||
if (huaweiFamily && await _isHmsAvailable()) {
|
||||
final token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
return (
|
||||
token: token,
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getFcmToken() {
|
||||
return _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _isHuaweiDevice() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
final brand = info.brand.toLowerCase();
|
||||
final manufacturer = info.manufacturer.toLowerCase();
|
||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
||||
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHmsAvailable() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||
return available ?? false;
|
||||
} catch (e, st) {
|
||||
log('HMS availability check failed: $e', stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHuaweiPushTokenNative() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||
return token;
|
||||
} on PlatformException catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
if (!isHmsClientUnavailable(e)) {
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class NotificationPermissionRequester {
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
|
||||
Future<void> requestOnce(Future<void> Function() requestPermission) async {
|
||||
final inFlight = _inFlight;
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
final future = requestPermission();
|
||||
_inFlight = future;
|
||||
try {
|
||||
await future;
|
||||
} finally {
|
||||
if (identical(_inFlight, future)) {
|
||||
_inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
lib/core/push/hms_client_error.dart
Normal file
36
lib/core/push/hms_client_error.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
bool isHuaweiFamilyBrand({
|
||||
required String brand,
|
||||
required String manufacturer,
|
||||
}) {
|
||||
final normalizedBrand = brand.toLowerCase();
|
||||
final normalizedManufacturer = manufacturer.toLowerCase();
|
||||
return normalizedBrand.contains('huawei') ||
|
||||
normalizedBrand.contains('honor') ||
|
||||
normalizedManufacturer.contains('huawei') ||
|
||||
normalizedManufacturer.contains('honor');
|
||||
}
|
||||
|
||||
/// HMS Core is missing or rejects this app.
|
||||
///
|
||||
/// Honor devices with Google services return `907135003: client api invalid`.
|
||||
/// That is a device capability, not an app crash: the caller should use FCM.
|
||||
bool isHmsClientUnavailable(Object error) {
|
||||
if (error is! PlatformException) return false;
|
||||
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
|
||||
return message.contains('907135003') ||
|
||||
message.contains('client api invalid');
|
||||
}
|
||||
|
||||
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
|
||||
String pushPlatform({
|
||||
required bool isIos,
|
||||
required bool huaweiFamily,
|
||||
required bool hmsAvailable,
|
||||
required bool hasHmsToken,
|
||||
}) {
|
||||
if (isIos) return 'ios';
|
||||
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
|
||||
return 'android';
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||
@@ -183,7 +184,14 @@ class OtherRoutes {
|
||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.createPlanScreen,
|
||||
name: 'createPlanScreen',
|
||||
builder: (context, state) {
|
||||
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||
return CreatePlanScreen(plan: plan);
|
||||
},
|
||||
),
|
||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||
|
||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||
String inlineSvgCss(String svg) {
|
||||
final styleBlockPattern = RegExp(
|
||||
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final classProps = <String, Map<String, String>>{};
|
||||
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||
final css = match.group(1) ?? '';
|
||||
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||
for (final rule in rulePattern.allMatches(css)) {
|
||||
final className = rule.group(1)!;
|
||||
final body = rule.group(2)!;
|
||||
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||
for (final declaration in body.split(';')) {
|
||||
final colon = declaration.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = declaration.substring(0, colon).trim();
|
||||
final value = declaration.substring(colon + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
props[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = svg.replaceAll(styleBlockPattern, '');
|
||||
if (classProps.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||
Match match,
|
||||
) {
|
||||
final classes = match
|
||||
.group(2)!
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((name) => name.isNotEmpty);
|
||||
final attrs = <String, String>{};
|
||||
for (final className in classes) {
|
||||
final props = classProps[className];
|
||||
if (props != null) {
|
||||
attrs.addAll(props);
|
||||
}
|
||||
}
|
||||
if (attrs.isEmpty) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final attrString = attrs.entries
|
||||
.map((entry) => '${entry.key}="${entry.value}"')
|
||||
.join(' ');
|
||||
return ' $attrString';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
11
lib/core/utils/url_utils.dart
Normal file
11
lib/core/utils/url_utils.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Joins an API media path with [baseUrl].
|
||||
///
|
||||
/// Uploaded photos come back as relative paths (`/storage/...`).
|
||||
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
|
||||
String joinBaseUrl(String baseUrl, String path) {
|
||||
final normalized = path.toLowerCase();
|
||||
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
|
||||
return path;
|
||||
}
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
@@ -38,15 +38,35 @@ class WsManager {
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _connectionEstablishedSubscription;
|
||||
|
||||
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||
///
|
||||
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||
StreamController<void> _reconnectController = StreamController<void>.broadcast();
|
||||
|
||||
Stream<ChannelReadEvent>? _directChatStream;
|
||||
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
|
||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||
|
||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||
|
||||
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||
///
|
||||
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||
/// сообщения (S5).
|
||||
Stream<void> get reconnected => _reconnectController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_reconnectController.isClosed) {
|
||||
_reconnectController = StreamController<void>.broadcast();
|
||||
}
|
||||
|
||||
if (client == null) {
|
||||
try {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
@@ -105,8 +125,7 @@ class WsManager {
|
||||
|
||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
handleConnectionEstablished();
|
||||
}
|
||||
});
|
||||
_eventSubscription = client?.eventStream.listen((event) {
|
||||
@@ -170,6 +189,7 @@ class WsManager {
|
||||
}
|
||||
|
||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_privateDirectChatChannel = client?.privateChannel(
|
||||
'$directChatChannelName.$chatId',
|
||||
@@ -180,6 +200,8 @@ class WsManager {
|
||||
},
|
||||
),
|
||||
);
|
||||
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||
}
|
||||
|
||||
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||
@@ -293,12 +315,27 @@ class WsManager {
|
||||
_privateNotificationChannel?.unsubscribe();
|
||||
}
|
||||
|
||||
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||
///
|
||||
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||
/// Pusher-клиента.
|
||||
void handleConnectionEstablished() {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
_presenceDirectChatChannel?.subscribe();
|
||||
_reconnectController.add(null);
|
||||
}
|
||||
|
||||
void subscribeDirectChatChannel() {
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
}
|
||||
|
||||
void unsubscribeDirectChatChannel() {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
|
||||
void unsubscribePresenceDirectChatChannel() {
|
||||
@@ -331,6 +368,8 @@ class WsManager {
|
||||
_privateDirectChatChannel = null;
|
||||
_privateMessagesChannel = null;
|
||||
_privateNotificationChannel = null;
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,20 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
@@ -25,6 +33,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class CustomPopup extends StatelessWidget {
|
||||
final bool isAuthor;
|
||||
final bool isAuthorAdmin;
|
||||
final ComplainedContent? complainedContentType;
|
||||
final String path;
|
||||
final String? slug;
|
||||
@@ -32,12 +41,14 @@ class CustomPopup extends StatelessWidget {
|
||||
final FeedBlogPostModel? feedBlogPostModel;
|
||||
final Recipe? recipe;
|
||||
final Diet? diet;
|
||||
final PlanModel? plan;
|
||||
final String userSlug;
|
||||
final VoidCallback? onPostsChanged;
|
||||
|
||||
const CustomPopup({
|
||||
super.key,
|
||||
required this.isAuthor,
|
||||
this.isAuthorAdmin = false,
|
||||
this.complainedContentType,
|
||||
required this.path,
|
||||
this.id,
|
||||
@@ -45,10 +56,14 @@ class CustomPopup extends StatelessWidget {
|
||||
this.feedBlogPostModel,
|
||||
this.recipe,
|
||||
this.diet,
|
||||
this.plan,
|
||||
required this.userSlug,
|
||||
this.onPostsChanged,
|
||||
});
|
||||
|
||||
bool get _isAdminAuthor =>
|
||||
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -81,7 +96,103 @@ class CustomPopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
|
||||
Future<void> _openEditor(BuildContext context) async {
|
||||
final router = GoRouter.of(context);
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
|
||||
if (result != null && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _reloadEditedContent(BuildContext context) {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<RecipesCubit>().loadRecipesList();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<DietsCubit>().loadDiets();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<MyPlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool _showsEditAction() {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
return feedBlogPostModel != null || id != null;
|
||||
case ComplainedContent.recipe:
|
||||
return recipe != null;
|
||||
case ComplainedContent.diet:
|
||||
return diet != null;
|
||||
case ComplainedContent.plan:
|
||||
return plan != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
|
||||
final entries = <PopupMenuEntry>[];
|
||||
|
||||
void addItem(String icon, String text, Function() onTap) {
|
||||
@@ -105,12 +216,9 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
}
|
||||
if (_showsEditAction()) {
|
||||
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||
_openEditor(context);
|
||||
});
|
||||
}
|
||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||
@@ -167,19 +275,21 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!_isAdminAuthor) {
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
addItem(
|
||||
'assets/icons/complain.svg',
|
||||
'Заблокировать автора',
|
||||
|
||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||
|
||||
class NetworkSvgPicture extends StatefulWidget {
|
||||
const NetworkSvgPicture({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||
}
|
||||
|
||||
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||
late final Future<String> _svgFuture = _load(widget.url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _svgFuture,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox(width: widget.width, height: widget.height);
|
||||
}
|
||||
return SvgPicture.string(
|
||||
snapshot.data!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> _load(String url) async {
|
||||
final cached = _inlinedSvgCache[url];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to load SVG from $url');
|
||||
}
|
||||
final processed = inlineSvgCss(response.body);
|
||||
_inlinedSvgCache[url] = processed;
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
if (isDrafted == true) {
|
||||
text = 'cохранена в черновиках';
|
||||
} else if (isUpdated == true) {
|
||||
text = 'успешно обновлена';
|
||||
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||
} else if (isPlan == true) {
|
||||
text = 'опубликован';
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final int? highlightedCommentId;
|
||||
|
||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
||||
const BlogPostScreen({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.highlightedCommentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||
@@ -45,7 +50,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||
child: BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
create: (BuildContext context) => commentsCubit
|
||||
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||
builder: (context, blogPostState) {
|
||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
body: const Loader(),
|
||||
);
|
||||
}
|
||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
final bool isLiked =
|
||||
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes =
|
||||
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1.2,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -163,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
||||
if (isSent) {
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.incrementCommentsState(widget.id);
|
||||
}
|
||||
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
@@ -174,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2
|
||||
? appTheme.blueA400
|
||||
: Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -197,23 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
||||
isAuthor:
|
||||
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||
false,
|
||||
isAuthorAdmin:
|
||||
blogPostState.blogPostModel?.author.isAdmin ??
|
||||
false,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(widget.id)}',
|
||||
id: widget.id,
|
||||
feedBlogPostModel: blogPostState.blogPostModel,
|
||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
||||
userSlug:
|
||||
blogPostState.blogPostModel?.author.authorSlug ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 160 + MediaQuery.of(context).padding.bottom,
|
||||
), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra:
|
||||
blogPostState.blogPostModel?.author.authorSlug,
|
||||
),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
@@ -222,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorProfileIcon ??
|
||||
'',
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorName ??
|
||||
'',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
||||
(blogPostState.blogPostModel?.createdAt ??
|
||||
DateTime.now())
|
||||
.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -244,10 +344,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
if (blogPostState
|
||||
.blogPostModel
|
||||
?.description
|
||||
.isNotEmpty ??
|
||||
false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText:
|
||||
blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
@@ -256,16 +366,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (post == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(
|
||||
post.image,
|
||||
);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
return BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||
videoEmbedUrl:
|
||||
embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(
|
||||
post.videoUrlThumb,
|
||||
)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'blog-post-screen-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
@@ -275,22 +400,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: isLiked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
iconColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
backgroundColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.1)
|
||||
: null,
|
||||
textColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<BlogPostCubit>().toggleLike();
|
||||
await context
|
||||
.read<BlogPostCubit>()
|
||||
.toggleLike();
|
||||
// ignore: use_build_context_synchronously
|
||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
||||
final updated = context
|
||||
.read<BlogPostCubit>()
|
||||
.state
|
||||
.blogPostModel;
|
||||
if (updated != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.updateFeedBlogPostList(
|
||||
updated,
|
||||
updated.wasLikedByMe,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -298,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
_iconButton(
|
||||
icon: Icons.reply_rounded,
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final box =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) &
|
||||
box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 16,
|
||||
color: appTheme.gray500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
blogPostState.blogPostModel?.totalViews
|
||||
.toString() ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -320,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 0.4,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -328,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -353,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
|
||||
InkWell _iconButton({
|
||||
required IconData icon,
|
||||
String? text,
|
||||
Function()? onTap,
|
||||
Color? iconColor,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap ?? () {},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? appTheme.gray100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -369,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (text != null)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: textColor ?? appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -37,6 +37,17 @@ class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||
}
|
||||
}
|
||||
|
||||
void setImagesError(String? error) {
|
||||
if (state.imagesError == error &&
|
||||
state.formError == null &&
|
||||
state.errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(imagesError: error, formError: null, errorMessage: null),
|
||||
);
|
||||
}
|
||||
|
||||
void resetFormErrors() {
|
||||
if (!state.hasValidationErrors &&
|
||||
!state.isSuccess &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
@@ -38,11 +39,15 @@ class BlogPostCreateScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
static const _minImageWidth = 500;
|
||||
static const _minImageHeight = 500;
|
||||
|
||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||
bool shrinkImage = false;
|
||||
late StreamSubscription<bool> keyboardSubscription;
|
||||
final List<FeedGalleryImage> savedImages = [];
|
||||
final List<XFile> pendingImages = [];
|
||||
final Set<String> _undersizedPendingPaths = {};
|
||||
final List<int> mediaIdsToDelete = [];
|
||||
final titleController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
@@ -531,10 +536,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null && _remainingSlots > 0) {
|
||||
setState(() {
|
||||
pendingImages.add(image);
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
await _addPendingImages([image]);
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -561,10 +563,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
limit: _remainingSlots,
|
||||
);
|
||||
if (images.isNotEmpty) {
|
||||
setState(() {
|
||||
pendingImages.addAll(images.take(_remainingSlots));
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
await _addPendingImages(
|
||||
images.take(_remainingSlots).toList(),
|
||||
);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
@@ -603,9 +604,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
}
|
||||
|
||||
void _removePendingImage(int index) {
|
||||
final path = pendingImages[index].path;
|
||||
setState(() {
|
||||
pendingImages.removeAt(index);
|
||||
_undersizedPendingPaths.remove(path);
|
||||
});
|
||||
_syncImagesSizeError();
|
||||
}
|
||||
|
||||
void _onReorderGallery(int oldIndex, int newIndex) {
|
||||
@@ -670,164 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: enableMdPanel || isPreviewMode
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(
|
||||
Icons.format_list_numbered,
|
||||
_applyNumberedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(
|
||||
Icons.format_list_bulleted,
|
||||
_applyBulletedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
Widget _buildEditorToolbar(BuildContext context) {
|
||||
return Row(
|
||||
key: const ValueKey('blogPostCreateEditorToolbar'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
final editorToolbarVisible = enableMdPanel || isPreviewMode;
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(),
|
||||
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
bloc: blogPostCubit,
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.titleError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
hasError: state.descriptionError != null,
|
||||
onChanged: (_) => blogPostCubit.clearDescriptionError(),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.titleError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
hasError: state.descriptionError != null,
|
||||
onChanged: (_) =>
|
||||
blogPostCubit.clearDescriptionError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildGallery(),
|
||||
if (state.imagesError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.imagesError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rutube, VK или OK',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: appTheme.blueGray300,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
controller: videoUrlController,
|
||||
hasError: state.videoUrlError != null,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||
),
|
||||
if (state.videoUrlError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.videoUrlError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (editorToolbarVisible) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildGallery(),
|
||||
if (state.imagesError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.imagesError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rutube, VK или OK',
|
||||
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
controller: videoUrlController,
|
||||
hasError: state.videoUrlError != null,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||
),
|
||||
if (state.videoUrlError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.videoUrlError!),
|
||||
],
|
||||
if (state.formError != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
],
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
const SizedBox(height: 80),
|
||||
_buildEditorToolbar(context),
|
||||
],
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state.formError != null) ...[
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -839,6 +864,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_undersizedPendingPaths.isNotEmpty) {
|
||||
blogPostCubit.setImagesError(S.current.blog_post_image_min_size_error);
|
||||
return;
|
||||
}
|
||||
|
||||
final title = titleController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
final videoUrl = videoUrlController.text.trim();
|
||||
@@ -889,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
onTap: isSubmitting ? null : _submitBlogPost,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: isSubmitting
|
||||
? const Center(child: Loader())
|
||||
? const SizedBox(height: 52, child: Center(child: Loader()))
|
||||
: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -907,6 +936,50 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addPendingImages(List<XFile> images) async {
|
||||
if (images.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final undersizedPaths = <String>{};
|
||||
for (final image in images) {
|
||||
if (!await _isImageLargeEnough(image)) {
|
||||
undersizedPaths.add(image.path);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
pendingImages.addAll(images);
|
||||
_undersizedPendingPaths.addAll(undersizedPaths);
|
||||
});
|
||||
_syncImagesSizeError();
|
||||
}
|
||||
|
||||
void _syncImagesSizeError() {
|
||||
final minSizeError = S.current.blog_post_image_min_size_error;
|
||||
if (_undersizedPendingPaths.isNotEmpty) {
|
||||
blogPostCubit.setImagesError(minSizeError);
|
||||
return;
|
||||
}
|
||||
if (blogPostCubit.state.imagesError == minSizeError) {
|
||||
blogPostCubit.clearImagesError();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isImageLargeEnough(XFile image) async {
|
||||
try {
|
||||
final bytes = await File(image.path).readAsBytes();
|
||||
final descriptor = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
final codec = await ui.instantiateImageCodecFromBuffer(descriptor);
|
||||
final frame = await codec.getNextFrame();
|
||||
final width = frame.image.width;
|
||||
final height = frame.image.height;
|
||||
return width >= _minImageWidth && height >= _minImageHeight;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildGallery() {
|
||||
final itemHeight = shrinkImage ? 72.0 : 112.0;
|
||||
|
||||
@@ -946,6 +1019,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
child: _buildGalleryTile(
|
||||
index: index,
|
||||
height: itemHeight,
|
||||
hasError: _undersizedPendingPaths.contains(pending.path),
|
||||
imageProvider: FileImage(File(pending.path)),
|
||||
onRemove: () => _removePendingImage(pendingIndex),
|
||||
),
|
||||
@@ -985,6 +1059,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
required double height,
|
||||
required ImageProvider imageProvider,
|
||||
required VoidCallback onRemove,
|
||||
bool hasError = false,
|
||||
}) {
|
||||
return ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
@@ -992,12 +1067,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
width: height,
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: hasError ? Border.all(color: appTheme.red, width: 2) : null,
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Image(image: imageProvider, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
State<BottomNavigationBarWidget> createState() =>
|
||||
_BottomNavigationBarWidgetState();
|
||||
}
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/consultations-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
} else {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final logo = SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
colorFilter: isProduction
|
||||
? null
|
||||
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
);
|
||||
if (isProduction) return logo;
|
||||
return Stack(
|
||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
||||
border: Border.all(
|
||||
color: _devAccentColor.withValues(alpha: 0.6),
|
||||
width: 1.5,
|
||||
),
|
||||
color: _devAccentColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: logo,
|
||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
decoration: BoxDecoration(
|
||||
color: _devAccentColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _devAccentColor.withValues(alpha: 0.4),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'DEV',
|
||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.3,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
||||
final effectiveIndex = indexFromRoute >= 0
|
||||
? indexFromRoute
|
||||
: currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 &&
|
||||
indexFromRoute != currentBottomNavBarIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
if (mounted) {
|
||||
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
profileModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
final hasUnreadMessages =
|
||||
state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: hasUnreadMessages
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
||||
hasUnreadMessages
|
||||
? state.chatsWithUnreadMessagesCount.toString()
|
||||
: '',
|
||||
style: TextStyle(
|
||||
color: hasUnreadMessages
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
||||
showBadge:
|
||||
state is NotificationsStateLoaded &&
|
||||
state.hasUnreadNotifications,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] ==
|
||||
'/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
|
||||
@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
|
||||
// recipient-relative и для второго устройства отправителя всегда false,
|
||||
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
|
||||
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
|
||||
// только для обратной совместимости парсинга.
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||
|
||||
@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||
StreamSubscription<void>? _reconnectSubscription;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
void Function(bool isOnline)? _presenceOnlineListener;
|
||||
var uuid = const Uuid();
|
||||
|
||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
||||
ChatRoomCubit({
|
||||
required this.chatRepository,
|
||||
required this.wsManager,
|
||||
required this.cardsRepository,
|
||||
}) : super(const ChatRoomState.loading());
|
||||
|
||||
void setTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 5),
|
||||
(_) => updateChosenPlanState(),
|
||||
);
|
||||
}
|
||||
|
||||
void cancelTimer() {
|
||||
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (currentPage < lastPage && !isLoading) {
|
||||
isLoading = true;
|
||||
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
||||
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||
|
||||
try {
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage + 1,
|
||||
);
|
||||
|
||||
isLoading = false;
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
isLoading: isLoading,
|
||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
||||
chatMessages: [
|
||||
...current.chatMessages,
|
||||
...chatMessages.messagesList,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -86,11 +100,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
|
||||
void _setCard({required List<CardModel> cards}) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
emit(currentState.copyWith(hasCard: true));
|
||||
}
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||
///
|
||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
||||
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
|
||||
Future<String?> initChat(
|
||||
String? uuid, {
|
||||
String? orderId,
|
||||
bool sendInitialMessage = false,
|
||||
}) async {
|
||||
try {
|
||||
emit(const ChatRoomState.loading());
|
||||
|
||||
@@ -108,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
int? resolvedChosenPlanId;
|
||||
|
||||
if (orderId != null && orderId.isNotEmpty) {
|
||||
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
||||
final resolved = await chatRepository.resolveOrderIdToConversation(
|
||||
orderId: orderId,
|
||||
);
|
||||
resolvedUuid = resolved.conversationUuid;
|
||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||
}
|
||||
@@ -117,15 +134,23 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
throw ArgumentError('initChat requires uuid or orderId');
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
_activeChatUuid = resolvedUuid;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
resolvedUuid,
|
||||
currentPage,
|
||||
);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||
_listenToReconnect();
|
||||
emit(
|
||||
ChatRoomState.loaded(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
hasCard: false,
|
||||
),
|
||||
);
|
||||
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
log(e.toString());
|
||||
typingSubscription?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
_reconnectSubscription?.cancel();
|
||||
_reconnectSubscription = null;
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
|
||||
void _listenToReconnect() {
|
||||
_reconnectSubscription?.cancel();
|
||||
_reconnectSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(refreshCurrentChat());
|
||||
});
|
||||
}
|
||||
|
||||
/// Догружает первую страницу сообщений после восстановления соединения (S5).
|
||||
///
|
||||
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
|
||||
/// сообщения добавляются, существующие не дублируются. Порядок — по
|
||||
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
|
||||
Future<void> refreshCurrentChat() async {
|
||||
final uuid = _activeChatUuid;
|
||||
if (uuid == null) return;
|
||||
|
||||
try {
|
||||
final page = await chatRepository.getChatMessages(uuid, 1);
|
||||
lastPage = page.meta.lastPage;
|
||||
_emitLoaded((current) {
|
||||
final byId = {
|
||||
for (final message in current.chatMessages) message.id: message,
|
||||
};
|
||||
for (final message in page.messagesList) {
|
||||
byId.putIfAbsent(message.id, () => message);
|
||||
}
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return current.copyWith(chatMessages: merged);
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
|
||||
Future<void> refreshChat() async {
|
||||
final uuid = _activeChatUuid;
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId =
|
||||
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage,
|
||||
);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
|
||||
_emitLoaded(
|
||||
(latest) => latest.copyWith(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
chatMessages: chatMessages.messagesList,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateChosenPlanState() async {
|
||||
try {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||
if (chosenPlanId != null) {
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
}
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||
if (chosenPlanId == null) {
|
||||
return;
|
||||
}
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -172,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
||||
if (planId == null) return false;
|
||||
final stringDate = date.format('yyyy-MM-dd');
|
||||
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate});
|
||||
await chatRepository.choseStartPlanDate(
|
||||
id: planId,
|
||||
date: {'start_date': stringDate},
|
||||
);
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith.chatRoom(
|
||||
chosenPlan: chosenPlan,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -183,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
|
||||
Future<void> cancelChosenPlan({
|
||||
required String chatId,
|
||||
required int planId,
|
||||
}) async {
|
||||
try {
|
||||
await chatRepository.cancelChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
||||
@@ -193,38 +313,129 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||
bool _emitLoaded(
|
||||
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
|
||||
) {
|
||||
if (isClosed) {
|
||||
return false;
|
||||
}
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
emit(update(currentState));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({
|
||||
required String uuid,
|
||||
required String chatId,
|
||||
}) async {
|
||||
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
|
||||
// пропущенного после реконнекта (S5), даже если сокет появится позже.
|
||||
_activeChatUuid = uuid;
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
_activeChatUuid = uuid;
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
messagesSubscription = null;
|
||||
typingSubscription = null;
|
||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||
wsManager.subscribeDirectChatChannel();
|
||||
await _subscribeToPresenceChannel(chatId: chatId);
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
final currentState = state as ChatRoomStateLoaded;
|
||||
|
||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
||||
// они обрабатываются через ответ API в sendMessage
|
||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
||||
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
||||
});
|
||||
messagesSubscription = wsManager.directChatStream?.listen(
|
||||
_onDirectChatEvent,
|
||||
onError: (Object e, StackTrace st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
},
|
||||
);
|
||||
|
||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||
);
|
||||
EasyDebounce.debounce(
|
||||
'client-typing',
|
||||
const Duration(seconds: 3),
|
||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
||||
() => _emitLoaded(
|
||||
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||
try {
|
||||
if (addIncomingMessage(
|
||||
ChatMessageModel.fromJson(
|
||||
jsonDecode(event.data) as Map<String, dynamic>,
|
||||
),
|
||||
)) {
|
||||
unawaited(_playIncomingSound());
|
||||
}
|
||||
} catch (e, st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playIncomingSound() async {
|
||||
try {
|
||||
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||
///
|
||||
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
|
||||
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
|
||||
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
|
||||
/// обновляет S3 через `new-chat-messages.{senderId}`.
|
||||
///
|
||||
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
|
||||
/// сообщение, реально добавленное).
|
||||
bool addIncomingMessage(ChatMessageModel message) {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
|
||||
// это эхо уже добавленного оптимистичного сообщения).
|
||||
final alreadyExists = currentState.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
);
|
||||
if (alreadyExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final added = _emitLoaded((current) {
|
||||
if (current.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
)) {
|
||||
return current;
|
||||
}
|
||||
return current.copyWith(
|
||||
chatMessages: [message, ...current.chatMessages],
|
||||
isLoading: isLoading,
|
||||
);
|
||||
});
|
||||
if (!added) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
|
||||
if (!isOwn) {
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
}
|
||||
|
||||
return !isOwn;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||
_presenceOnlineListener = (isOnline) {
|
||||
unawaited(_handleCounterpartOnlineChanged(
|
||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
));
|
||||
unawaited(
|
||||
_handleCounterpartOnlineChanged(
|
||||
counterpartField:
|
||||
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
),
|
||||
);
|
||||
};
|
||||
wsManager.addPresenceDirectChatListener(
|
||||
counterpartUserId: counterpart.id,
|
||||
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
? currentState.chatRoom.users.receiver
|
||||
: currentState.chatRoom.users.sender;
|
||||
|
||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
if (counterpart.isOnline == isOnline &&
|
||||
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -337,63 +552,55 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||
Future<bool> sendMessage({
|
||||
String? data,
|
||||
List<File>? files,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
final compressedFiles = files != null
|
||||
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||
: null;
|
||||
try {
|
||||
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||
} catch (_) {}
|
||||
|
||||
// Создаем временное сообщение с состоянием loading
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -messageUuid.hashCode.abs(),
|
||||
data: data ?? '',
|
||||
mediaData: compressedFiles,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: messageUuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages,
|
||||
...current.chatMessages,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
||||
return serverMessage.copyWith(
|
||||
uuid: messageUuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId ?? serverMessage.roomId,
|
||||
authorId: serverMessage.authorId,
|
||||
updatedAt: serverMessage.updatedAt,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
),
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: data,
|
||||
files: compressedFiles,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: messageUuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
@@ -411,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
||||
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||
try {
|
||||
if (state is ChatRoomStateLoaded) {
|
||||
final ids = messagesIds.map((e) => e.id).toList();
|
||||
await chatRepository.markMessagesAsRead(chatId, ids);
|
||||
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
|
||||
for (var elem in messagesIds) {
|
||||
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
|
||||
}
|
||||
return true;
|
||||
if (state is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||
final idSet = messageIds.toSet();
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages
|
||||
.map(
|
||||
(message) => idSet.contains(message.id)
|
||||
? message.copyWith(isRead: true)
|
||||
: message,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
@@ -433,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _replaceOptimisticMessage({
|
||||
required String messageUuid,
|
||||
required ChatMessageModel serverMessage,
|
||||
}) {
|
||||
_emitLoaded((current) {
|
||||
final sentMessage = serverMessage.copyWith(
|
||||
uuid: messageUuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
);
|
||||
final withoutDuplicate = current.chatMessages.where(
|
||||
(message) =>
|
||||
message.id != serverMessage.id || message.uuid == messageUuid,
|
||||
);
|
||||
final hasOptimistic = withoutDuplicate.any(
|
||||
(message) => message.uuid == messageUuid,
|
||||
);
|
||||
if (!hasOptimistic) {
|
||||
return current.copyWith(
|
||||
chatMessages: [sentMessage, ...withoutDuplicate],
|
||||
);
|
||||
}
|
||||
return current.copyWith(
|
||||
chatMessages: withoutDuplicate.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return sentMessage.copyWith(
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId?.isNotEmpty == true
|
||||
? elem.roomId
|
||||
: serverMessage.roomId,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> getPaymentLink() async {
|
||||
try {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
if (planId == null) return null;
|
||||
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
|
||||
final paymentModel = await chatRepository.getPaymentLink(
|
||||
id: planId,
|
||||
paymentProvider: 'alfa-bank',
|
||||
);
|
||||
return paymentModel.paymentUrl;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
EasyDebounce.cancel('client-typing');
|
||||
EasyDebounce.cancel('searchChatRoom');
|
||||
if (_presenceOnlineListener != null) {
|
||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
_timer?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
typingSubscription?.cancel();
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
await _reconnectSubscription?.cancel();
|
||||
super.close();
|
||||
}
|
||||
|
||||
void deleteMessage(String uuid) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList()));
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
chatMessages: currentState.chatMessages
|
||||
.where((message) => message.uuid != uuid)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resendMessage({required String uuid, required String userSlug}) async {
|
||||
Future<void> resendMessage({
|
||||
required String uuid,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
||||
final message = currentState.chatMessages.firstWhereOrNull(
|
||||
(message) => message.uuid == uuid,
|
||||
);
|
||||
if (message != null) {
|
||||
try {
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
try {
|
||||
unawaited(
|
||||
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
// Определяем, есть ли файлы для повторной отправки
|
||||
List<File>? filesToResend;
|
||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||
// Если mediaData содержит File объекты, используем их
|
||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
||||
final firstItem = message.mediaData!.first;
|
||||
if (firstItem is File) {
|
||||
filesToResend = message.mediaData!.cast<File>();
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -uuid.hashCode.abs(),
|
||||
data: message.data,
|
||||
mediaData: message.mediaData,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: uuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
||||
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера
|
||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return serverMessage.copyWith(
|
||||
uuid: uuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId ?? serverMessage.roomId,
|
||||
authorId: serverMessage.authorId,
|
||||
updatedAt: serverMessage.updatedAt,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
),
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: message.data,
|
||||
files: filesToResend,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: uuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final WsManager wsManager;
|
||||
StreamSubscription? newIncomingMessagesSubscription;
|
||||
StreamSubscription? newLastMessageSubscription;
|
||||
StreamSubscription<void>? reconnectedSubscription;
|
||||
final Set<int> _presentedVideoCallMessageIds = {};
|
||||
|
||||
/// Test seam. Production uses CallKit.
|
||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||
Future<void> Function()? dismissIncomingVideoCall;
|
||||
|
||||
ChatsCubit({
|
||||
required this.chatRepository,
|
||||
required this.wsManager,
|
||||
required this.profileRepository,
|
||||
this.presentIncomingVideoCall,
|
||||
this.dismissIncomingVideoCall,
|
||||
}) : super(ChatsState()) {
|
||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
|
||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
|
||||
message,
|
||||
) {
|
||||
_setNewLastMessage(message);
|
||||
});
|
||||
}
|
||||
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await newIncomingMessagesSubscription?.cancel();
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||
event,
|
||||
) {
|
||||
_handleNewMessageViaSocket(event.data);
|
||||
});
|
||||
|
||||
await reconnectedSubscription?.cancel();
|
||||
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(getChatRooms());
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -47,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
_query = query;
|
||||
emit(state.copyWith(state: BaseState.loading));
|
||||
try {
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: query,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||
emit(
|
||||
@@ -60,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
page++;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
||||
);
|
||||
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
page = 1;
|
||||
_query = query;
|
||||
try {
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: query,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||
emit(
|
||||
@@ -84,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
emit(
|
||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
||||
);
|
||||
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
try {
|
||||
if (state.isLoadingMoreChats || lastPage == page) return;
|
||||
emit(state.copyWith(isLoadingMoreChats: true));
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: _query,
|
||||
page: page,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -134,15 +159,20 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
chatRooms: updatedChatRooms,
|
||||
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
|
||||
chatsWithUnreadMessagesCount: isChatRead
|
||||
? state.chatsWithUnreadMessagesCount - 1
|
||||
: state.chatsWithUnreadMessagesCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||
try {
|
||||
final json = jsonDecode(data);
|
||||
final message = ChatMessageModel.fromJson(json['message']);
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = ChatMessageModel.fromJson(json);
|
||||
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
final currentChatRooms = [...state.chatRooms];
|
||||
|
||||
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||
if (chatRoom.id == message.roomId) {
|
||||
wasChanged = true;
|
||||
if (chatRoom.unreadMessagesCount == 0) {
|
||||
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||
final isDuplicate =
|
||||
message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||
// Собственное сообщение (второе устройство автора) обновляет
|
||||
// lastMessage, но не создаёт непрочитанное (S3).
|
||||
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||
willIncrementUnreadChats = true;
|
||||
}
|
||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
||||
return chatRoom.copyWith(
|
||||
lastMessage: message,
|
||||
unreadMessagesCount: isOwn || isDuplicate
|
||||
? chatRoom.unreadMessagesCount
|
||||
: chatRoom.unreadMessagesCount + 1,
|
||||
);
|
||||
}
|
||||
return chatRoom;
|
||||
}).toList();
|
||||
@@ -165,15 +206,83 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
chatRooms: updatedChatRooms,
|
||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
|
||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats
|
||||
? state.chatsWithUnreadMessagesCount + 1
|
||||
: state.chatsWithUnreadMessagesCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_presentVideoCallFromMessage(
|
||||
message,
|
||||
isOwn: isOwn,
|
||||
chatRooms: currentChatRooms,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
void _presentVideoCallFromMessage(
|
||||
ChatMessageModel message, {
|
||||
required bool isOwn,
|
||||
required List<ChatRoom> chatRooms,
|
||||
}) {
|
||||
final kind = VideoCallChatSignal.kindOf(message);
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi());
|
||||
return;
|
||||
}
|
||||
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final caller = _callerFromRooms(message, chatRooms);
|
||||
final payload = VideoCallChatSignal.incomingPayload(
|
||||
message: message,
|
||||
callerName: caller?.name,
|
||||
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||
);
|
||||
if (payload == null) return;
|
||||
unawaited(_showIncomingVideoCall(payload));
|
||||
}
|
||||
|
||||
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
|
||||
try {
|
||||
await (presentIncomingVideoCall ??
|
||||
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
|
||||
} catch (e, st) {
|
||||
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingVideoCallUi() async {
|
||||
try {
|
||||
await (dismissIncomingVideoCall ??
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi)();
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'ChatsCubit: failed to dismiss incoming video call: $e',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChatUser? _callerFromRooms(
|
||||
ChatMessageModel message,
|
||||
List<ChatRoom> chatRooms,
|
||||
) {
|
||||
for (final room in chatRooms) {
|
||||
if (room.id != message.roomId) continue;
|
||||
final from = room.users.fromChatUser;
|
||||
final to = room.users.toChatUser;
|
||||
if (from.id == message.authorId) return from;
|
||||
if (to.id == message.authorId) return to;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setNewLastMessage(ChatMessageModel message) {
|
||||
final chats = state.chatRooms.map((chat) {
|
||||
if (chat.id == message.roomId) {
|
||||
@@ -188,6 +297,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
newIncomingMessagesSubscription?.cancel();
|
||||
reconnectedSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,12 @@ class ChatScreen extends StatefulWidget {
|
||||
final String? orderId;
|
||||
final bool? sendInitialMessage;
|
||||
|
||||
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
||||
const ChatScreen({
|
||||
this.uuid,
|
||||
this.orderId,
|
||||
super.key,
|
||||
required this.sendInitialMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
@@ -56,9 +61,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final scrollController = ScrollController();
|
||||
final TextEditingController textEditingController = TextEditingController();
|
||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||
final List<({int id, int index})> unreadMessagesIds = [];
|
||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
final List<int> unreadMessageIds = [];
|
||||
final showableChatBadgeStates =
|
||||
ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
late AppLifecycleListener _listener;
|
||||
List<File> pickedFiles = [];
|
||||
|
||||
@@ -109,13 +114,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
void _onScroll() {
|
||||
final uuid = conversationUuid;
|
||||
if (uuid == null) return;
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent &&
|
||||
!scrollController.position.outOfRange) {
|
||||
chatRoomCubit.loadNextPage(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
EasyDebounce.cancel('mark_as_read');
|
||||
scrollController.dispose();
|
||||
_listener.dispose();
|
||||
chatRoomCubit.close();
|
||||
@@ -168,9 +175,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||
final chatCounterpart = resolveChatCounterpart(
|
||||
users: state.chatRoom.users,
|
||||
currentUserId: sl<WsManager>().userId,
|
||||
);
|
||||
final chosenPlan = state.chatRoom.chosenPlan;
|
||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final amIConsultant =
|
||||
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final showUserDetailsButton = [
|
||||
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
||||
ChosenPlanStateEnum.statePendingPayment,
|
||||
@@ -186,22 +197,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
final showAboutOrderButton =
|
||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
state.chatRoom.chosenPlan != null &&
|
||||
state.chatRoom.chosenPlan!.chosenPlanState !=
|
||||
ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
BannerType? bannerType;
|
||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
if (chosenPlan == null ||
|
||||
chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
bannerType = null;
|
||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
} else if (amIConsultant &&
|
||||
!state.hasCard &&
|
||||
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.card;
|
||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
} else if (amIConsultant &&
|
||||
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.date;
|
||||
} else if (!amIConsultant &&
|
||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
||||
(chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
|
||||
chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.statePendingPayment)) {
|
||||
bannerType = BannerType.payment;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
|
||||
amIConsultant) {
|
||||
bannerType = BannerType.collectCharge;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
||||
} else if (chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateLastDayFinished &&
|
||||
!amIConsultant) {
|
||||
bannerType = BannerType.finishPlan;
|
||||
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||
} else if (chosenOption?.hasPersonalDiet == true &&
|
||||
amIConsultant &&
|
||||
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||
bannerType = BannerType.setPersonalDiet;
|
||||
}
|
||||
|
||||
@@ -223,25 +249,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
state.chatRoom.users.receiver.profilePhotoUrl!,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEBF4FF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
|
||||
state.chatRoom.users.receiver.name.split(' ').first[0] +
|
||||
state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF7F9CF5),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -253,10 +294,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
state.chatRoom.users.receiver.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||
color: PrimaryColors().black,
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
||||
firstChild: Text(
|
||||
'Печатает...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
),
|
||||
firstCurve: Curves.easeIn,
|
||||
secondCurve: Curves.easeOut,
|
||||
secondChild: ChatUserOnlineStatusLine(
|
||||
@@ -265,7 +313,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
showOnlineDot: false,
|
||||
),
|
||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
crossFadeState: state.isTyping
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
),
|
||||
],
|
||||
@@ -277,7 +327,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||
child: _ChatHeaderVideoCallButton(
|
||||
onPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -291,44 +343,133 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Container(
|
||||
color: appTheme.whiteA700,
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.chatMessages.length) {
|
||||
return const Padding(padding: EdgeInsets.all(8), child: Loader());
|
||||
}
|
||||
child: RefreshIndicator(
|
||||
onRefresh: chatRoomCubit.refreshChat,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.chatMessages.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Loader(),
|
||||
);
|
||||
}
|
||||
|
||||
if ((index + 1 != state.chatMessages.length &&
|
||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
if ((index + 1 != state.chatMessages.length &&
|
||||
state
|
||||
.chatMessages[index]
|
||||
.updatedAt
|
||||
.day !=
|
||||
state
|
||||
.chatMessages[index + 1]
|
||||
.updatedAt
|
||||
.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor =
|
||||
state.chatRoom.users.receiver.id !=
|
||||
state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage =
|
||||
message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options2') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options3') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null &&
|
||||
messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: ValueKey(
|
||||
'msg-${message.uuid ?? message.id}',
|
||||
),
|
||||
onVisibilityChanged:
|
||||
(VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
visibleFraction:
|
||||
info.visibleFraction,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor =
|
||||
state.chatRoom.users.receiver.id !=
|
||||
state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
final isPlanOrderMessage =
|
||||
message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
messageOption =
|
||||
chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options2') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options3') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null &&
|
||||
messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
@@ -337,126 +478,94 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||
key: ValueKey(
|
||||
'msg-${message.uuid ?? message.id}',
|
||||
),
|
||||
onVisibilityChanged:
|
||||
(VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
count: unreadMessagesIdsCopy.length,
|
||||
message: message,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
visibleFraction:
|
||||
info.visibleFraction,
|
||||
);
|
||||
unreadMessagesIdsCopy.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
},
|
||||
itemCount:
|
||||
state.chatMessages.length +
|
||||
(state.isLoading ? 1 : 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (showUserDetailsButton) ...[
|
||||
// OrderCard(
|
||||
// plan: chosenPlan.plan,
|
||||
// isCustomerView: !amIConsultant,
|
||||
// optionType: chosenPlan.optionType,
|
||||
// onCancel: () async {
|
||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||
// },
|
||||
// ),
|
||||
if (amIConsultant)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: CustomOutlinedButton(
|
||||
text: S.current.user_info,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold
|
||||
.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(
|
||||
BorderSide.none,
|
||||
),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIds.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
||||
unreadMessagesIds.clear();
|
||||
});
|
||||
}
|
||||
onPressed: () async {
|
||||
GoRouter.of(context).push(
|
||||
UserDetailsScreen.path,
|
||||
extra:
|
||||
state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (showUserDetailsButton) ...[
|
||||
// OrderCard(
|
||||
// plan: chosenPlan.plan,
|
||||
// isCustomerView: !amIConsultant,
|
||||
// optionType: chosenPlan.optionType,
|
||||
// onCancel: () async {
|
||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||
// },
|
||||
// ),
|
||||
if (amIConsultant)
|
||||
),
|
||||
],
|
||||
if (bannerType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: CustomOutlinedButton(
|
||||
text: S.current.user_info,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: NotificationBanner(
|
||||
bannerType: bannerType,
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet:
|
||||
chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (bannerType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: NotificationBanner(
|
||||
bannerType: bannerType,
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -481,19 +590,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_isImageFile(pickedFiles[i].path)
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
|
||||
final imageIndex = _imageIndexAt(pickedFiles, i);
|
||||
final imageFiles = pickedFiles
|
||||
.where(
|
||||
(f) => _isImageFile(f.path),
|
||||
)
|
||||
.toList();
|
||||
final imageIndex = _imageIndexAt(
|
||||
pickedFiles,
|
||||
i,
|
||||
);
|
||||
|
||||
context.pushTransparentRoute(
|
||||
PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex),
|
||||
PhotoViewScreen(
|
||||
imageUrls: imageFiles
|
||||
.map((e) => e.path)
|
||||
.toList(),
|
||||
isLocal: true,
|
||||
index: imageIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: pickedFiles[i].path,
|
||||
transitionOnUserGestures: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 104,
|
||||
width: 104,
|
||||
child: Image.file(
|
||||
pickedFiles[i],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -510,8 +640,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.gray50,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -528,14 +665,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
|
||||
onTap: () => showImagePicker(
|
||||
state.chatRoom.users.receiver.slug,
|
||||
),
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: Transform.rotate(
|
||||
angle: pi / 4,
|
||||
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
|
||||
child: const Icon(
|
||||
Icons.attach_file_rounded,
|
||||
size: 30,
|
||||
color: Color(0xFF858E99),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -547,24 +690,46 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
hintText: 'Сообщение',
|
||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -579,12 +744,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final blockedState = blockedUsersCubit.state;
|
||||
|
||||
if (blockedState is BlockedUsersStateLoaded) {
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
|
||||
final isBlocked = blockedState.profilesList.any(
|
||||
(profile) =>
|
||||
profile.slug ==
|
||||
state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
if (isBlocked) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
||||
content: Text(
|
||||
'Вы не можете отправить сообщение заблокированному пользователю',
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -593,10 +764,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
|
||||
if (textEditingController.text.isNotEmpty ||
|
||||
pickedFiles.isNotEmpty) {
|
||||
chatRoomCubit.sendMessage(
|
||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
||||
files: pickedFiles.isEmpty ? null : pickedFiles,
|
||||
data: textEditingController.text.isEmpty
|
||||
? null
|
||||
: textEditingController.text.trim(),
|
||||
files: pickedFiles.isEmpty
|
||||
? null
|
||||
: pickedFiles,
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
setState(() {
|
||||
@@ -606,10 +782,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
},
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -628,12 +811,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: AboutOrderButton(
|
||||
onTap: () async {
|
||||
if (amIConsultant) {
|
||||
GoRouter.of(
|
||||
context,
|
||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
||||
GoRouter.of(context).push(
|
||||
InfoByOrderScreen.path,
|
||||
extra: {
|
||||
'planId': state.chatRoom.chosenPlan?.id,
|
||||
'customerSlug': state.chatRoom.users.receiver.slug,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
|
||||
GoRouter.of(context).push(
|
||||
InfoByOrderScreen.path,
|
||||
extra: {'planId': state.chatRoom.chosenPlan?.id},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -658,7 +848,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
|
||||
child: Text(
|
||||
fileName,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -706,7 +902,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
||||
// }
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_camera),
|
||||
SizedBox(width: 8),
|
||||
Text('Фото'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -715,13 +917,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
List<XFile> images = await picker.pickMultiImage();
|
||||
if (images != []) {
|
||||
setState(() {
|
||||
pickedFiles.addAll(images.map((image) => File(image.path)).toList());
|
||||
pickedFiles.addAll(
|
||||
images.map((image) => File(image.path)).toList(),
|
||||
);
|
||||
});
|
||||
// List<File> files = images.map((image) => File(image.path)).toList();
|
||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||
}
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_library_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Галерея'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -753,15 +963,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (result != null) {
|
||||
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
setState(() {
|
||||
pickedFiles.addAll(result.paths.map((path) => File(path!)).toList());
|
||||
pickedFiles.addAll(
|
||||
result.paths.map((path) => File(path!)).toList(),
|
||||
);
|
||||
});
|
||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||
}
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.file_present_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Файлы'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isDestructiveAction: true,
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -772,13 +994,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: Text(
|
||||
DateFormat.yMMMMd('RU').format(date),
|
||||
style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8393A1),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
||||
void _openVideoCall(
|
||||
BuildContext context,
|
||||
ChatRoomStateLoaded state,
|
||||
ChatUserModel chatCounterpart,
|
||||
) {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
@@ -793,6 +1023,43 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _queueMarkAsRead({
|
||||
required BuildContext context,
|
||||
required String chatId,
|
||||
required ChatMessageModel message,
|
||||
required bool isCurrentUserAuthor,
|
||||
required double visibleFraction,
|
||||
}) {
|
||||
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!unreadMessageIds.contains(message.id)) {
|
||||
unreadMessageIds.add(message.id);
|
||||
}
|
||||
EasyDebounce.debounce(
|
||||
'mark_as_read',
|
||||
const Duration(milliseconds: 500),
|
||||
() async {
|
||||
final ids = List<int>.from(unreadMessageIds);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
unreadMessageIds.clear();
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(
|
||||
chatId,
|
||||
ids,
|
||||
);
|
||||
if (!result || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||
chatId: chatId,
|
||||
count: ids.length,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble({
|
||||
required BuildContext context,
|
||||
required ChatRoomStateLoaded state,
|
||||
@@ -801,11 +1068,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
required ChatMessageModel message,
|
||||
required bool? amIConsultant,
|
||||
}) {
|
||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
||||
if (message.type == ChatMessageType.videoCallStarted &&
|
||||
!isCurrentUserAuthor) {
|
||||
return VideoCallChatMessageWidget(
|
||||
text: message.data.trim(),
|
||||
sendTime: message.updatedAt,
|
||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
onVideoCallPressed: () =>
|
||||
_openVideoCall(context, state, chatCounterpart),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -844,7 +1113,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.videocam_rounded,
|
||||
color: appTheme.blueA400,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
|
||||
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
final category = state.category;
|
||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
s.complaint_question,
|
||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.spam,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.explicitImages,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||
const SizedBox(height: 4),
|
||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||
]
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_ComplainButton(
|
||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
||||
),
|
||||
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
super.didChangeDependencies();
|
||||
final s = S.of(context);
|
||||
if (tags.isEmpty && descriptions.isEmpty) {
|
||||
tags.addAll([
|
||||
(false, s.misrepresentation),
|
||||
(false, s.fraud),
|
||||
]);
|
||||
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||
|
||||
descriptions.addAll([
|
||||
s.misrepresentation_description,
|
||||
s.fraud_description,
|
||||
]);
|
||||
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
child: Text(
|
||||
descriptions[index],
|
||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
||||
),
|
||||
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint_sent,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.could_not_send_complain,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Loader();
|
||||
return const SizedBox(height: 52, child: Loader());
|
||||
}
|
||||
return CustomOutlinedButton(
|
||||
text: s.send_complain,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () async {
|
||||
if (isDisabled) {
|
||||
context.read<ComplainingCubit>().setDescription('');
|
||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
||||
child: Ink(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
category.getTranslatedText(),
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
const Spacer(),
|
||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||
],
|
||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
||||
explicitImage;
|
||||
|
||||
String getTranslatedText() => switch (this) {
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
|
||||
String getValue() => switch (this) {
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
}
|
||||
|
||||
Future<void> complain() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final category = state.category;
|
||||
final id = state.contentId;
|
||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
case ComplainedContent.diet:
|
||||
if (id != null) {
|
||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
if (slug != null) {
|
||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.recipe:
|
||||
if (slug != null) {
|
||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.profile:
|
||||
if (slug != null) {
|
||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.posts:
|
||||
if (id != null) {
|
||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.comment:
|
||||
if (id != null) {
|
||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
}
|
||||
emit(state.copyWith(wasSentSuccessfully: false));
|
||||
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'consultant_profile_model.freezed.dart';
|
||||
@@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
final last = lastName ?? '';
|
||||
final first = firstName ?? '';
|
||||
final innValue = inn ?? '';
|
||||
final snilsValue = snils ?? '';
|
||||
final series = passportSeries ?? '';
|
||||
final number = passportNumber ?? '';
|
||||
final issueDate = passportIssueDate ?? '';
|
||||
final issuer = passportIssuer ?? '';
|
||||
final issuerCode = passportIssuerCode ?? '';
|
||||
final birth = birthDate ?? '';
|
||||
final birthPlaceValue = birthPlace ?? '';
|
||||
final registration = registrationAddress ?? '';
|
||||
final residence = residenceAddress ?? '';
|
||||
final citizenshipValue = citizenship ?? '';
|
||||
|
||||
return last.trim().isNotEmpty &&
|
||||
first.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
||||
issuer.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
||||
birthPlaceValue.trim().isNotEmpty &&
|
||||
registration.trim().isNotEmpty &&
|
||||
residence.trim().isNotEmpty &&
|
||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
||||
}
|
||||
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
@@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||
? const <String, String>{}
|
||||
: PassportDataValidator.validate(consultant);
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
@@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
fieldErrors: nextFieldErrors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> validatePassportFields() {
|
||||
final errors = PassportDataValidator.validate(state.consultant);
|
||||
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||
return errors;
|
||||
}
|
||||
|
||||
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||
return;
|
||||
}
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
@@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: 'Заполните паспортные данные',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
@@ -68,6 +69,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -300,6 +304,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Фамилия',
|
||||
controller: lastNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('last_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -310,6 +316,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('first_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -320,6 +328,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('middle_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -332,6 +342,7 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
hasError: errors.containsKey('inn'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -446,6 +457,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_series'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -460,6 +474,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_number'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
|
||||
@@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
previous.fieldErrors.isEmpty &&
|
||||
current.fieldErrors.isNotEmpty &&
|
||||
current.error == null,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
pageController.jumpToPage(5);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: const Text(
|
||||
'Заполните паспортные данные',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
final passportErrors =
|
||||
registrationConsultantCubit
|
||||
.validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
pageController.jumpToPage(5);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit
|
||||
.state
|
||||
.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
@@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ class LaravelValidationErrors {
|
||||
}
|
||||
|
||||
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
const _genericNetworkSummary =
|
||||
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
|
||||
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||
/// Raw backend messages are never shown in the UI.
|
||||
@@ -85,8 +86,7 @@ String mapConsultantValidationMessage({
|
||||
raw.contains('required') ||
|
||||
raw.contains('обязательн') ||
|
||||
raw.contains('required_if');
|
||||
final isAccepted =
|
||||
raw.contains('accepted') || field.contains('agreement');
|
||||
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||
final isUrl = raw.contains('url') || field.contains('link');
|
||||
final isFormat =
|
||||
raw.contains('format') ||
|
||||
@@ -154,6 +154,9 @@ String mapConsultantValidationMessage({
|
||||
'passport_number' => 'Некорректный номер паспорта',
|
||||
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||
'last_name' => 'Некорректная фамилия',
|
||||
'first_name' => 'Некорректное имя',
|
||||
'middle_name' => 'Некорректное отчество',
|
||||
'birth_date' => 'Некорректная дата рождения',
|
||||
'citizenship' => 'Некорректный код гражданства',
|
||||
'sbp_phone' => 'Некорректный телефон СБП',
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
|
||||
/// Client-side validation for individual-person passport fields.
|
||||
///
|
||||
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
|
||||
/// look the same in the UI. Dates also reject non-calendar values and
|
||||
/// dates after today.
|
||||
abstract final class PassportDataValidator {
|
||||
static const futureDateMessage = 'Дата не может быть в будущем';
|
||||
|
||||
/// Alfa Bank `personalInformation.{last,first,middle}Name` pattern.
|
||||
static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$');
|
||||
|
||||
static Map<String, String> validate(
|
||||
ConsultantProfileModel consultant, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final today = now ?? DateTime.now();
|
||||
final errors = <String, String>{};
|
||||
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'last_name',
|
||||
value: consultant.lastName,
|
||||
pattern: alfaPersonNamePattern,
|
||||
emptyMessage: 'Укажите фамилию',
|
||||
invalidMessage: 'Некорректная фамилия',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'first_name',
|
||||
value: consultant.firstName,
|
||||
pattern: alfaPersonNamePattern,
|
||||
emptyMessage: 'Укажите имя',
|
||||
invalidMessage: 'Некорректное имя',
|
||||
);
|
||||
_optionalPattern(
|
||||
errors,
|
||||
key: 'middle_name',
|
||||
value: consultant.middleName,
|
||||
pattern: alfaPersonNamePattern,
|
||||
invalidMessage: 'Некорректное отчество',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'inn',
|
||||
value: consultant.inn,
|
||||
pattern: RegExp(r'^\d{12}$'),
|
||||
emptyMessage: 'Укажите ИНН',
|
||||
invalidMessage: 'Некорректный ИНН',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'snils',
|
||||
value: consultant.snils,
|
||||
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
|
||||
emptyMessage: 'Укажите СНИЛС',
|
||||
invalidMessage: 'Некорректный СНИЛС',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'passport_series',
|
||||
value: consultant.passportSeries,
|
||||
pattern: RegExp(r'^\d{4}$'),
|
||||
emptyMessage: 'Укажите серию паспорта',
|
||||
invalidMessage: 'Некорректная серия паспорта',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'passport_number',
|
||||
value: consultant.passportNumber,
|
||||
pattern: RegExp(r'^\d{6}$'),
|
||||
emptyMessage: 'Укажите номер паспорта',
|
||||
invalidMessage: 'Некорректный номер паспорта',
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'passport_issuer',
|
||||
value: consultant.passportIssuer,
|
||||
emptyMessage: 'Укажите, кем выдан паспорт',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'passport_issuer_code',
|
||||
value: consultant.passportIssuerCode,
|
||||
pattern: RegExp(r'^\d{3}-\d{3}$'),
|
||||
emptyMessage: 'Укажите код подразделения',
|
||||
invalidMessage: 'Некорректный код подразделения',
|
||||
);
|
||||
_requireDate(
|
||||
errors,
|
||||
key: 'passport_issue_date',
|
||||
value: consultant.passportIssueDate,
|
||||
emptyMessage: 'Укажите дату выдачи паспорта',
|
||||
invalidMessage: 'Некорректная дата выдачи',
|
||||
today: today,
|
||||
);
|
||||
_requireDate(
|
||||
errors,
|
||||
key: 'birth_date',
|
||||
value: consultant.birthDate,
|
||||
emptyMessage: 'Укажите дату рождения',
|
||||
invalidMessage: 'Некорректная дата рождения',
|
||||
today: today,
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'birth_place',
|
||||
value: consultant.birthPlace,
|
||||
emptyMessage: 'Укажите место рождения',
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'registration_address',
|
||||
value: consultant.registrationAddress,
|
||||
emptyMessage: 'Укажите адрес регистрации',
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'residence_address',
|
||||
value: consultant.residenceAddress,
|
||||
emptyMessage: 'Укажите адрес проживания',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'citizenship',
|
||||
value: consultant.citizenship,
|
||||
pattern: RegExp(r'^[A-Za-z]{2}$'),
|
||||
emptyMessage: 'Укажите гражданство',
|
||||
invalidMessage: 'Некорректный код гражданства',
|
||||
);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static void _requireText(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required String emptyMessage,
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
errors[key] = emptyMessage;
|
||||
}
|
||||
}
|
||||
|
||||
static void _requirePattern(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required RegExp pattern,
|
||||
required String emptyMessage,
|
||||
required String invalidMessage,
|
||||
}) {
|
||||
final raw = value?.trim() ?? '';
|
||||
if (raw.isEmpty) {
|
||||
errors[key] = emptyMessage;
|
||||
return;
|
||||
}
|
||||
if (!pattern.hasMatch(raw)) {
|
||||
errors[key] = invalidMessage;
|
||||
}
|
||||
}
|
||||
|
||||
static void _optionalPattern(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required RegExp pattern,
|
||||
required String invalidMessage,
|
||||
}) {
|
||||
final raw = value?.trim() ?? '';
|
||||
if (raw.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!pattern.hasMatch(raw)) {
|
||||
errors[key] = invalidMessage;
|
||||
}
|
||||
}
|
||||
|
||||
static void _requireDate(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required String emptyMessage,
|
||||
required String invalidMessage,
|
||||
required DateTime today,
|
||||
}) {
|
||||
final raw = value?.trim() ?? '';
|
||||
if (raw.isEmpty) {
|
||||
errors[key] = emptyMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
|
||||
if (match == null) {
|
||||
errors[key] = invalidMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
final year = int.parse(match.group(1)!);
|
||||
final month = int.parse(match.group(2)!);
|
||||
final day = int.parse(match.group(3)!);
|
||||
final parsed = DateTime(year, month, day);
|
||||
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||
errors[key] = invalidMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
final todayDate = DateTime(today.year, today.month, today.day);
|
||||
if (parsed.isAfter(todayDate)) {
|
||||
errors[key] = futureDateMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
selectedGlossary = null;
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
||||
),
|
||||
),
|
||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: addPortion,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: addPortion,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
||||
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
|
||||
class CreatePlanScreen extends StatelessWidget {
|
||||
static const name = 'createPlanScreen';
|
||||
static const path = '/create-Plan-screen';
|
||||
const CreatePlanScreen({super.key});
|
||||
const CreatePlanScreen({super.key, this.plan});
|
||||
|
||||
final PlanModel? plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
final cubit = sl<CreatePlanCubit>();
|
||||
final existingPlan = plan;
|
||||
if (existingPlan != null) {
|
||||
cubit.loadExisting(existingPlan);
|
||||
}
|
||||
return cubit;
|
||||
},
|
||||
child: const CreatePlanScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||
builder: (context, state) {
|
||||
if (state.planCreated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
context.pop();
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
context.pop(true);
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
s.adding_plan,
|
||||
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
key: const ValueKey('plan-name'),
|
||||
hintText: s.plan_label_desc,
|
||||
initialValue: state.planData.name,
|
||||
hasError: state.validationState.titleHasError,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setName(value);
|
||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
children: [
|
||||
MarkdownEditor(
|
||||
key: _mdKey,
|
||||
initialText: state.planData.details,
|
||||
hintText: s.plan_desc_example,
|
||||
hasError: state.validationState.descriptionHasError,
|
||||
minLines: 6,
|
||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
onPressed: () async {
|
||||
await context.read<CreatePlanCubit>().publishPlan();
|
||||
},
|
||||
text: s.publish,
|
||||
text: isEditing ? 'Обновить' : s.publish,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
|
||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
|
||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
final CreatePlanRepository createPlanRepository;
|
||||
PlanModel? sourcePlan;
|
||||
|
||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
||||
|
||||
void loadExisting(PlanModel plan) {
|
||||
sourcePlan = plan;
|
||||
final durationIndex = durationValues.indexOf(plan.duration);
|
||||
emit(
|
||||
state.copyWith(
|
||||
durationIndex: durationIndex >= 0 ? durationIndex : null,
|
||||
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
|
||||
planData: state.planData.copyWith(
|
||||
name: plan.name,
|
||||
details: plan.details,
|
||||
duration: plan.duration,
|
||||
state: plan.state,
|
||||
type: [
|
||||
plan.type.slim ?? false,
|
||||
plan.type.fit ?? false,
|
||||
plan.type.wellness ?? false,
|
||||
plan.type.behavior ?? false,
|
||||
],
|
||||
options: [
|
||||
_packetOrEmpty(plan.packet1Options),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PacketOptions _packetOrEmpty(PacketOptions? option) {
|
||||
if (option == null) {
|
||||
return PacketOptions.empty();
|
||||
}
|
||||
final price = option.price;
|
||||
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
|
||||
return option.copyWith(price: normalizedPrice);
|
||||
}
|
||||
|
||||
void setDetails(String text) {
|
||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||
validateDetails();
|
||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
}
|
||||
|
||||
bool validateImage() {
|
||||
if (state.planData.image == null) {
|
||||
if (state.planData.image == null && sourcePlan == null) {
|
||||
emit(state.copyWith.validationState(imageHasError: true));
|
||||
return false;
|
||||
}
|
||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
||||
'image': state.planData.image != null
|
||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
|
||||
filename: state.planData.image!.name)
|
||||
: null
|
||||
});
|
||||
if (state.planData.image != null) {
|
||||
final image = state.planData.image!;
|
||||
planMap['image'] = await MultipartFile.fromFile(
|
||||
(await ImageCompressUtility.compressFile(File(image.path))).path,
|
||||
filename: image.name,
|
||||
);
|
||||
}
|
||||
if (state.planData.options[1].short.isNotEmpty) {
|
||||
planMap.addAll({
|
||||
'packets_count': 3,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet2_options': {
|
||||
'short': state.planData.options[1].short,
|
||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[1].price),
|
||||
'description_md': state.planData.options[1].description,
|
||||
'items': Map.fromIterable(state.planData.options[1].items),
|
||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet3_options': {
|
||||
'short': state.planData.options[2].short,
|
||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[2].price),
|
||||
'description_md': state.planData.options[2].description,
|
||||
'items': Map.fromIterable(state.planData.options[2].items),
|
||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'packets_count': 1,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
final editingPlan = sourcePlan;
|
||||
if (editingPlan == null) {
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
} else {
|
||||
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
|
||||
}
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
emit(state.copyWith(isLoading: false, planCreated: false));
|
||||
final isForbidden = e is DioException && e.response?.statusCode == 403;
|
||||
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
|
||||
}
|
||||
}
|
||||
|
||||
int _optionPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return price.round();
|
||||
}
|
||||
return int.parse(price.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
|
||||
class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) {
|
||||
if (widget.items.length > widget.index &&
|
||||
controller.text != widget.items[widget.index]) {
|
||||
controller.text = widget.items[widget.index];
|
||||
}
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.validationState != current.validationState,
|
||||
buildWhen: (previous, current) =>
|
||||
previous.validationState != current.validationState,
|
||||
builder: (context, state) {
|
||||
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
|
||||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
|
||||
final hasError =
|
||||
state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.isItemsEmpty ||
|
||||
(state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.itemsErrors[widget.index] ??
|
||||
false);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
child: CustomTextFormField(
|
||||
controller: controller,
|
||||
hasError: hasError,
|
||||
hintText: widget.enableHint ? s.option_content_hint_text : null,
|
||||
hintText: widget.enableHint
|
||||
? s.option_content_hint_text
|
||||
: null,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setOptionContent(value, widget.index);
|
||||
context.read<CreatePlanCubit>().setOptionContent(
|
||||
value,
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
|
||||
context.read<CreatePlanCubit>().removeOptionContentItem(
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
color: appTheme.lightGray0,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/trash_can.svg',
|
||||
width: 20,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[
|
||||
if (state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.isItemsEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.addAtLeastOneItem)
|
||||
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
|
||||
ValidationErrorWidget(error: s.addAtLeastOneItem),
|
||||
] else if (state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.itemsErrors[widget.index] ??
|
||||
false) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols)
|
||||
]
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _OptionWidgetState extends State<OptionWidget> {
|
||||
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
static const _optionsCount = 3;
|
||||
|
||||
final creatorPriceController = TextEditingController()..text = '1000₽';
|
||||
|
||||
final firstOptionTitleController = TextEditingController();
|
||||
final secondOptionTitleController = TextEditingController();
|
||||
final thirdOptionTitleController = TextEditingController();
|
||||
|
||||
final firstOptionDescriptionController = TextEditingController();
|
||||
final secondOptionDescriptionController = TextEditingController();
|
||||
final thirdOptionDescriptionController = TextEditingController();
|
||||
final List<TextEditingController> _customerPriceControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(text: '1200₽'),
|
||||
);
|
||||
final List<TextEditingController> _titleControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<TextEditingController> _descriptionControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final creatorPriceController = TextEditingController(text: '1000₽');
|
||||
|
||||
int? _syncedOptionIndex;
|
||||
bool firstExplanationVisible = false;
|
||||
bool isChecked = false;
|
||||
bool secondExplanationVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
firstOptionCustomerPriceController.addListener(() {
|
||||
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
firstOptionCustomerPriceController.text = '$text₽';
|
||||
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
secondOptionCustomerPriceController.addListener(() {
|
||||
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
secondOptionCustomerPriceController.text = '$text₽';
|
||||
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
thirdOptionCustomerPriceController.addListener(() {
|
||||
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
thirdOptionCustomerPriceController.text = '$text₽';
|
||||
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
for (final controller in _customerPriceControllers) {
|
||||
controller.addListener(() => _ensureRubleSuffix(controller));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _customerPriceControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final controller in _titleControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final controller in _descriptionControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
creatorPriceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureRubleSuffix(TextEditingController controller) {
|
||||
if (!controller.text.endsWith('₽')) {
|
||||
final text = controller.text.replaceAll('₽', '');
|
||||
controller.text = '$text₽';
|
||||
controller.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncController(TextEditingController controller, String value) {
|
||||
if (controller.text == value) {
|
||||
return;
|
||||
}
|
||||
controller.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCustomerPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return '${price.toStringAsFixed(0)}₽';
|
||||
}
|
||||
final parsed = double.tryParse(price.toString().replaceAll('₽', ''));
|
||||
return '${(parsed ?? 0).toStringAsFixed(0)}₽';
|
||||
}
|
||||
|
||||
void _applyOptionToControllers(int index, PacketOptions option) {
|
||||
_syncController(_titleControllers[index], option.short);
|
||||
_syncController(_descriptionControllers[index], option.description ?? '');
|
||||
_syncController(
|
||||
_customerPriceControllers[index],
|
||||
_formatCustomerPrice(option.price),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncSelectedOption(CreatePlanState state) {
|
||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||
final option = state.planData.options[selectedOptionIndex];
|
||||
final price = double.tryParse(
|
||||
_customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''),
|
||||
);
|
||||
final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
|
||||
if (_syncedOptionIndex != selectedOptionIndex) {
|
||||
_syncedOptionIndex = selectedOptionIndex;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_applyOptionToControllers(selectedOptionIndex, option);
|
||||
creatorPriceController.text = creatorPrice;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (creatorPriceController.text != creatorPrice) {
|
||||
creatorPriceController.text = creatorPrice;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||
_syncSelectedOption(state);
|
||||
final optionNameHintText = switch (selectedOptionIndex) {
|
||||
0 => s.economy,
|
||||
2 => s.comfort,
|
||||
_ => s.standard,
|
||||
};
|
||||
final optionTitleController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionTitleController,
|
||||
2 => thirdOptionTitleController,
|
||||
_ => secondOptionTitleController,
|
||||
};
|
||||
final optionDescriptionController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionDescriptionController,
|
||||
2 => thirdOptionDescriptionController,
|
||||
_ => secondOptionDescriptionController,
|
||||
};
|
||||
final customerPriceController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionCustomerPriceController,
|
||||
2 => thirdOptionCustomerPriceController,
|
||||
_ => secondOptionCustomerPriceController,
|
||||
};
|
||||
final optionTitleController = _titleControllers[selectedOptionIndex];
|
||||
final optionDescriptionController =
|
||||
_descriptionControllers[selectedOptionIndex];
|
||||
final customerPriceController =
|
||||
_customerPriceControllers[selectedOptionIndex];
|
||||
final optionItems = state.planData.options[selectedOptionIndex].items;
|
||||
final optionValidation =
|
||||
state.validationState.optionsValidationState[selectedOptionIndex];
|
||||
|
||||
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
|
||||
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
|
||||
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
|
||||
return MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: optionNameHintText,
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
|
||||
hasError: optionValidation.titleHasError,
|
||||
controller: optionTitleController,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setOptionTitle(value);
|
||||
},
|
||||
maxLength: 150,
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
|
||||
if (optionValidation.titleHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
child: CustomTextFormField(
|
||||
controller: optionDescriptionController,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
|
||||
hasError: optionValidation.descriptionHasError,
|
||||
maxLength: 1000,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
@@ -146,7 +197,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[
|
||||
if (optionValidation.descriptionHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const _PriceExplanationWidget(),
|
||||
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const _PriceExplanationWidget(),
|
||||
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: customerPriceController,
|
||||
textInputType: const TextInputType.numberWithOptions(decimal: true),
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
|
||||
textInputType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
hasError: optionValidation.priceHasError,
|
||||
onChanged: (value) {
|
||||
final price = double.tryParse(value.replaceAll('₽', ''));
|
||||
if (price != null) {
|
||||
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
reverse: false,
|
||||
itemBuilder: (context, index) {
|
||||
final items = state.planData.options[selectedOptionIndex].items;
|
||||
|
||||
if (items.isEmpty) {
|
||||
if (optionItems.isEmpty) {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
index: index,
|
||||
canBeDeleted: false,
|
||||
enableHint: true,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
} else {
|
||||
if (index < items.length) {
|
||||
if (index < optionItems.length) {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
canBeDeleted: true,
|
||||
enableHint: false,
|
||||
index: index,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
} else {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
canBeDeleted: false,
|
||||
enableHint: false,
|
||||
index: index,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
|
||||
itemCount: optionItems.length + 1,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
),
|
||||
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
||||
value: state
|
||||
.planData
|
||||
.options[selectedOptionIndex]
|
||||
.hasPersonalDiet,
|
||||
activeColor: appTheme.gray,
|
||||
checkColor: appTheme.gray,
|
||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
side: WidgetStateBorderSide.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
},
|
||||
),
|
||||
side: WidgetStateBorderSide.resolveWith((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
}),
|
||||
onChanged: (bool? value) {
|
||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
||||
context
|
||||
.read<CreatePlanCubit>()
|
||||
.togglePersonalizedDietOptionState();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.option_personal_diet_desc,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget {
|
||||
const _PriceExplanationWidget();
|
||||
|
||||
@override
|
||||
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
|
||||
State<_PriceExplanationWidget> createState() =>
|
||||
_PriceExplanationWidgetState();
|
||||
}
|
||||
|
||||
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
s.price_explanation,
|
||||
),
|
||||
child: Text(s.price_explanation),
|
||||
),
|
||||
),
|
||||
visible: visible,
|
||||
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
visible = !visible;
|
||||
});
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/question_mark.svg',
|
||||
height: 12,
|
||||
width: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
final s = S.of(context);
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedImage = state.planData.image;
|
||||
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
|
||||
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
|
||||
final ImageProvider? coverImage;
|
||||
if (selectedImage != null) {
|
||||
coverImage = FileImage(File(selectedImage.path));
|
||||
} else if (existingImage != null && existingImage.isNotEmpty) {
|
||||
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
|
||||
coverImage = NetworkImage(url);
|
||||
} else {
|
||||
coverImage = null;
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null,
|
||||
image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
|
||||
color: appTheme.gray50,
|
||||
),
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
||||
child: state.planData.image != null
|
||||
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||
child: selectedImage != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||
child: GestureDetector(
|
||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: hasCover
|
||||
? const SizedBox.shrink()
|
||||
: Stack(
|
||||
children: [
|
||||
DottedBorder(
|
||||
|
||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.createPlan(formData: formData);
|
||||
|
||||
}
|
||||
|
||||
|
||||
Future<void> updatePublishedPlan({
|
||||
required String slug,
|
||||
required Map<String, dynamic> model,
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,11 @@ abstract class CreatePlanService {
|
||||
Future<void> createPlan({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
|
||||
@POST('/api/plans/update/published/{slug}')
|
||||
@MultiPart()
|
||||
Future<void> updatePublishedPlan({
|
||||
@Path() required String slug,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
|
||||
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'diary_state.freezed.dart';
|
||||
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
|
||||
|
||||
const factory DiaryState.loading() = DiaryStateLoading;
|
||||
|
||||
const factory DiaryState.error({required String errorMessage}) = DiaryStateError;
|
||||
const factory DiaryState.error({required String errorMessage}) =
|
||||
DiaryStateError;
|
||||
|
||||
const factory DiaryState.loaded({
|
||||
required DiaryDayEntity diary,
|
||||
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
|
||||
@Default(false) bool isLoadingAddingProductInMeal,
|
||||
}) = DiaryStateLoaded;
|
||||
|
||||
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
|
||||
DiaryStateLoadedTomorrowWithDiet;
|
||||
const factory DiaryState.loadedTomorrowWithDiet({
|
||||
required DiaryFutureDietDayEntity diary,
|
||||
required DateTime selectedDay,
|
||||
required int selectedMeal,
|
||||
}) = DiaryStateLoadedTomorrowWithDiet;
|
||||
}
|
||||
|
||||
extension DiaryStateLoadedEditAccess on DiaryStateLoaded {
|
||||
bool get canEditDiary => canEditDiaryDay(
|
||||
isConsultantView: isConsultantView,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
closed: diary.closed,
|
||||
diaryDate: diary.date,
|
||||
);
|
||||
}
|
||||
|
||||
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// Finished diaries stay editable for this many calendar days back from today,
|
||||
/// including today. Older finished days stay read-only.
|
||||
const int diaryFinishedEditWindowInDays = 7;
|
||||
|
||||
bool canEditDiaryDay({
|
||||
required bool isConsultantView,
|
||||
required bool isDiaryCommented,
|
||||
required bool isDiaryFinished,
|
||||
required int closed,
|
||||
required String diaryDate,
|
||||
DateTime? now,
|
||||
}) {
|
||||
if (isConsultantView || isDiaryCommented || closed != 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isDiaryFinished) {
|
||||
return true;
|
||||
}
|
||||
final day = _calendarDate(diaryDate);
|
||||
if (day == null) {
|
||||
return false;
|
||||
}
|
||||
final clock = now ?? DateTime.now();
|
||||
final today = DateTime(clock.year, clock.month, clock.day);
|
||||
final oldestEditableDay = today.subtract(
|
||||
const Duration(days: diaryFinishedEditWindowInDays),
|
||||
);
|
||||
return !day.isBefore(oldestEditableDay) && !day.isAfter(today);
|
||||
}
|
||||
|
||||
DateTime? _calendarDate(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
final local = parsed.toLocal();
|
||||
return DateTime(local.year, local.month, local.day);
|
||||
}
|
||||
@@ -74,7 +74,12 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
}
|
||||
if (state is DiaryStateError) {
|
||||
return Scaffold(
|
||||
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<DiaryCubit>().fetchDiaryV2(
|
||||
needLoading: false,
|
||||
diaryId: widget.diaryId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,8 +89,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
? AppBar(toolbarHeight: 0)
|
||||
: AppBar(
|
||||
title: Text(
|
||||
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
state is DiaryStateLoaded
|
||||
? DateTime.parse(
|
||||
state.diary.date,
|
||||
).toLocal().format('d MMMM')
|
||||
: '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.diaryId == null) CalendarWidget(date: widget.date),
|
||||
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
|
||||
if (state is DiaryStateLoading) ...[
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
|
||||
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
isEnabled:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isWakeUpAndRetireEnabled,
|
||||
disable: !state.canEditDiary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (state is DiaryStateLoaded)
|
||||
MealWidget(
|
||||
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
|
||||
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
|
||||
showMood:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isMoodEnabled,
|
||||
showAddButton: state.canEditDiary,
|
||||
),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet)
|
||||
const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state.diary.comment.isNotEmpty) {
|
||||
return CommentForConsultantWidget(comment: state.diary.comment);
|
||||
return CommentForConsultantWidget(
|
||||
comment: state.diary.comment,
|
||||
);
|
||||
}
|
||||
if (state.isDiaryCommented || state.isDiaryFinished) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state is DiaryStateLoaded &&
|
||||
!state.isConsultantView &&
|
||||
!state.isDiaryFinished &&
|
||||
!state.isDiaryCommented)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _isDiaryFinishing
|
||||
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
setState(() {
|
||||
_isDiaryFinishing = true;
|
||||
});
|
||||
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
final isFinished = await context
|
||||
.read<DiaryCubit>()
|
||||
.finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
if (!isFinished && context.mounted) {
|
||||
showErrorDialog(context);
|
||||
}
|
||||
@@ -149,33 +178,64 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
|
||||
const MainBackgroundBouble(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: DayCommentWidget(),
|
||||
),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.consultantReview?.consultantComments.dayComment !=
|
||||
null) ...[
|
||||
const SizedBox(height: 12),
|
||||
MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
s.dayComment,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
|
||||
CommentContainer(
|
||||
comment:
|
||||
state
|
||||
.consultantReview
|
||||
?.consultantComments
|
||||
.dayComment ??
|
||||
'',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
state.isDiaryFinished &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: s.send,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id);
|
||||
await context
|
||||
.read<ConsultantCommentsCubit>()
|
||||
.submitComments(state.diary.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.76 - 32,
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.24 - 4,
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
),
|
||||
),
|
||||
if (widget.portion != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
widget.diaryCubit.updateProductInMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
widget.diaryCubit.updateProductInMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portion == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portion == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,9 @@ class CalendarWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
static const _kDaysOfWeekHeight = 16.0;
|
||||
static const _kRowHeight = 52.0;
|
||||
|
||||
CalendarFormat _calendarFormat = CalendarFormat.week;
|
||||
DateTime _focusedDay = DateTime.now();
|
||||
DateTime _selectedDay = DateTime.now();
|
||||
@@ -40,6 +43,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
final daysOfWeekHeight = _scaledDaysOfWeekHeight(context);
|
||||
return BlocConsumer<DiaryCubit, DiaryState>(
|
||||
listener: (BuildContext context, DiaryState state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
@@ -61,6 +66,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
locale: 'ru_RU',
|
||||
startingDayOfWeek: StartingDayOfWeek.monday,
|
||||
calendarFormat: _calendarFormat,
|
||||
daysOfWeekHeight: daysOfWeekHeight,
|
||||
rowHeight: textScaler.scale(_kRowHeight),
|
||||
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
|
||||
onDaySelected: (selectedDay, focusedDay) async {
|
||||
if (!isSameDay(_selectedDay, selectedDay)) {
|
||||
@@ -116,6 +123,18 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
double _scaledDaysOfWeekHeight(BuildContext context) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: 'Щy', style: DefaultTextStyle.of(context).style),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final height = painter.height;
|
||||
painter.dispose();
|
||||
return height.clamp(_kDaysOfWeekHeight, double.infinity);
|
||||
}
|
||||
|
||||
void _nextCalendarFormat() {
|
||||
setState(() {
|
||||
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length];
|
||||
|
||||
@@ -26,7 +26,8 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].mood.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Настроение пользователя после приема пищи'
|
||||
: 'Ваше настроение после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/mood_1.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_1.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].mood.toDouble() + 1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -142,12 +224,25 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillMood(
|
||||
diaryId: state.diary.id,
|
||||
mood:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/mood_2.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_2.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -52,7 +52,9 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
|
||||
final picker = ImagePicker();
|
||||
XFile? image = await picker.pickImage(source: ImageSource.camera);
|
||||
XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null) {
|
||||
diaryCubit.uploadPhotos(
|
||||
files: [File(image.path)],
|
||||
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
int mealIndex = state.selectedMeal - 1;
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty &&
|
||||
state.canEditDiary) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
showImagePicker(state.diary);
|
||||
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
),
|
||||
),
|
||||
if (!state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: IconButton.filled(
|
||||
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
),
|
||||
onPressed: () async {
|
||||
await diaryCubit.deletePhoto(
|
||||
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex],
|
||||
photo: state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos[currentSelectedPhotoIndex],
|
||||
diaryId: state.diary.id,
|
||||
);
|
||||
setState(() {
|
||||
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2;
|
||||
if (currentSelectedPhotoIndex ==
|
||||
state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos
|
||||
.length -
|
||||
1 &&
|
||||
currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex =
|
||||
state.diary.meals[mealIndex].photos.length -
|
||||
2;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -178,8 +194,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
for (
|
||||
int i = 0;
|
||||
i < state.diary.meals[mealIndex].photos.length + 1;
|
||||
i++
|
||||
) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length &&
|
||||
state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InkWell(
|
||||
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
height: 52,
|
||||
width: 52,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1) const SizedBox(width: 8),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1)
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
||||
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
super.initState();
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Widget _buildNutritionalInfo() {
|
||||
if (widget.portion.productNutritionalValueId == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Продукт отправлен на модерацию',
|
||||
style: TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2F80ED),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -57,7 +64,8 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
|
||||
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
|
||||
fats = (caloriesInfo.fats * widget.portion.quantity) / 100;
|
||||
carbohydrates = (caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
carbohydrates =
|
||||
(caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
} else {
|
||||
calories = caloriesInfo.energyOnPortion;
|
||||
proteins = caloriesInfo.proteinsOnPortion;
|
||||
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
Widget _buildNutritionChip(String label, int value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$label $value',
|
||||
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -93,16 +108,24 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
void didUpdateWidget(ProductWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
|
||||
return showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AddProductModal(state: state, portion: portion, diaryCubit: diaryCubit),
|
||||
builder: (context) => AddProductModal(
|
||||
state: state,
|
||||
portion: portion,
|
||||
diaryCubit: diaryCubit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,33 +136,36 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 12) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 12)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
);
|
||||
}
|
||||
if (state is DiaryStateLoaded) {
|
||||
final isDiaryCommented =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
|
||||
final isDiaryFinished =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.finished || state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
|
||||
final canEditPortion = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished && !widget.portion.fromDiet;
|
||||
final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
|
||||
final consultantComments = state.consultantReview?.consultantComments;
|
||||
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished;
|
||||
final canMarkAsEaten = state.canEditDiary;
|
||||
PortionComment? portionComment;
|
||||
if (consultantComments != null) {
|
||||
portionComment = consultantComments.portionsComments.firstWhereOrNull((elem) => elem.portionId == widget.portion.id);
|
||||
portionComment = consultantComments.portionsComments
|
||||
.firstWhereOrNull(
|
||||
(elem) => elem.portionId == widget.portion.id,
|
||||
);
|
||||
}
|
||||
if (widget.portion.portionState == PortionState.eatenDeleted) {
|
||||
return DottedBorder(
|
||||
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
color: Color(0xFF6D6F71),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
|
||||
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
|
||||
onTap: state.canEditDiary
|
||||
? () => diaryCubit.restore(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
)
|
||||
: null,
|
||||
trailing: state.canEditDiary
|
||||
? SvgPicture.asset('assets/icons/revert_product.svg')
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
// ignore: use_full_hex_values_for_flutter_colors
|
||||
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Widget? trailing;
|
||||
if (state.isConsultantView && !isDiaryCommented) {
|
||||
if (state.isConsultantView && !state.isDiaryCommented) {
|
||||
trailing = GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/edit.svg'),
|
||||
);
|
||||
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
|
||||
} else if (canEditPortion) {
|
||||
trailing = const Icon(Icons.more_vert_rounded);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null,
|
||||
onTap: canEditPortion
|
||||
? () => updateProductInMeal(state, widget.portion)
|
||||
: null,
|
||||
trailing: trailing,
|
||||
leading: widget.portion.fromDiet
|
||||
? Transform.scale(
|
||||
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
isEaten = value!;
|
||||
});
|
||||
if (isEaten) {
|
||||
diaryCubit.markEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.markEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
} else {
|
||||
diaryCubit.unmarkEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.unmarkEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
activeColor: appTheme.blueA400,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: const BorderSide(color: Color(0xFF809CB9)),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: PortionCommentWidget(enabled: showCommentWidget, portionId: widget.portion.id, comment: portionComment?.comment),
|
||||
child: PortionCommentWidget(
|
||||
enabled: showCommentWidget,
|
||||
portionId: widget.portion.id,
|
||||
comment: portionComment?.comment,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].satiety.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Сытость пользователя после приема пищи'
|
||||
: 'Ваша сытость после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_left.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_left.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety.toDouble() + 1
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety
|
||||
.toDouble() +
|
||||
1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -143,12 +227,25 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillSatiety(
|
||||
diaryId: state.diary.id,
|
||||
satiety:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_right.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_right.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -46,7 +46,8 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
Navigator.of(context).pop();
|
||||
diaryCubit.fillTime(
|
||||
diaryId: diaryId,
|
||||
time: '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
time:
|
||||
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -69,7 +70,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30)
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +99,9 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented ? null : () => showTimePicker(state.diary.id),
|
||||
onTap: state.canEditDiary
|
||||
? () => showTimePicker(state.diary.id)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
height: 32,
|
||||
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3),
|
||||
state.diary.meals[state.selectedMeal - 1].time
|
||||
.substring(
|
||||
0,
|
||||
state
|
||||
.diary
|
||||
.meals[state.selectedMeal - 1]
|
||||
.time
|
||||
.length -
|
||||
3,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
|
||||
@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: dietState.diet.author.authorIsMe,
|
||||
isAuthorAdmin: dietState.diet.author.isAdmin,
|
||||
path: 'diet/${dietState.diet.slug}',
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
id: dietState.diet.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AuthorCard extends StatelessWidget {
|
||||
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
|
||||
const AuthorCard({
|
||||
super.key,
|
||||
required this.author,
|
||||
required this.title,
|
||||
this.backgroundColor,
|
||||
this.titleStyle,
|
||||
this.customerProfile,
|
||||
});
|
||||
final Author author;
|
||||
final String title;
|
||||
final Color? backgroundColor;
|
||||
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: customerProfile != null
|
||||
? customerProfile!.slug
|
||||
: author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: backgroundColor ?? appTheme.cardBackground),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: backgroundColor ?? appTheme.cardBackground,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 16,
|
||||
bottom: 24,
|
||||
right: 16,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl.contains('https')
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon.contains('https')
|
||||
? author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
customerProfile != null ? customerProfile!.name : author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
customerProfile != null
|
||||
? customerProfile!.name
|
||||
: author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style:
|
||||
titleStyle ??
|
||||
TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (customerProfile == null) ...[
|
||||
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
'Консультант',
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
if (author.authorPlaceInRating != null)
|
||||
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
children: [
|
||||
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
UserDetailsScreen.name,
|
||||
extra: customerProfile!.slug,
|
||||
);
|
||||
},
|
||||
child: InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
|
||||
children: [
|
||||
Text(
|
||||
'Анкета пользователя',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,8 +149,18 @@ class AuthorCard extends StatelessWidget {
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
|
||||
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.whiteA700,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/arrow.svg',
|
||||
width: 12,
|
||||
height: 12,
|
||||
fit: BoxFit.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -14,12 +14,21 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class MoreOptions extends StatelessWidget {
|
||||
const MoreOptions({super.key, this.disableActions, required this.path, this.complainedContentType, this.slug, this.id});
|
||||
const MoreOptions({
|
||||
super.key,
|
||||
this.disableActions,
|
||||
required this.path,
|
||||
this.complainedContentType,
|
||||
this.slug,
|
||||
this.id,
|
||||
this.isAuthorAdmin = false,
|
||||
});
|
||||
final ComplainedContent? complainedContentType;
|
||||
final int? id;
|
||||
final String? slug;
|
||||
final String path;
|
||||
final bool? disableActions;
|
||||
final bool isAuthorAdmin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: disableActions == true
|
||||
? null
|
||||
: () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.complain,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
if (!isAuthorAdmin) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: disableActions == true
|
||||
? null
|
||||
: () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.complain,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
|
||||
],
|
||||
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -86,6 +86,8 @@ int parseDuration(dynamic duration) {
|
||||
|
||||
@freezed
|
||||
abstract class Author with _$Author {
|
||||
const Author._();
|
||||
|
||||
const factory Author({
|
||||
@JsonKey(name: "author_slug") required String authorSlug,
|
||||
@JsonKey(name: "author_name") required String authorName,
|
||||
@@ -93,9 +95,12 @@ abstract class Author with _$Author {
|
||||
@JsonKey(name: "author_is_me") required bool authorIsMe,
|
||||
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
|
||||
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
|
||||
}) = _Author;
|
||||
|
||||
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
|
||||
|
||||
bool get isAdmin => authorRoles.contains('admin');
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -26,16 +26,47 @@ class PostFeedMapper {
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
|
||||
return '';
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return '${sl<AppConfig>().baseUrl}$url';
|
||||
}
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
|
||||
static bool isPlaceholderMediaUrl(String url) {
|
||||
return url.toLowerCase().contains('element-image-placeholder');
|
||||
}
|
||||
|
||||
static bool hasRenderableImage(String? url) {
|
||||
if (url == null || url.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !isPlaceholderMediaUrl(url);
|
||||
}
|
||||
|
||||
static bool hasVisibleMedia({
|
||||
required String image,
|
||||
required List<FeedGalleryImage> images,
|
||||
String? videoEmbedUrl,
|
||||
}) {
|
||||
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (hasRenderableImage(image)) {
|
||||
return true;
|
||||
}
|
||||
return images.any((image) => hasRenderableImage(image.url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ class DietsCubit extends Cubit<DietsState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final type = _types[state.filterState.typeIndex];
|
||||
final duration = _durationValues[state.filterState.durationIndex];
|
||||
final sorting = _sortingValues[state.filterState.sortingIndex];
|
||||
|
||||
@@ -149,6 +149,7 @@ class PlansCubit extends Cubit<PlansState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final type = _types[state.filterState.typeIndex];
|
||||
final duration = _durationValues[state.filterState.durationIndex];
|
||||
final minPrice = state.filterState.priceFilter.$1;
|
||||
|
||||
@@ -46,6 +46,7 @@ class RecipesCubit extends Cubit<RecipesState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final recipesList = await feedRepository.getRecipes(
|
||||
tags: _selectedTags.toList(),
|
||||
query: query,
|
||||
|
||||
@@ -123,6 +123,19 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openCreateAndReloadFeed({
|
||||
required Future<Object?> Function() openCreate,
|
||||
required Future<void> Function() reload,
|
||||
}) async {
|
||||
final result = await openCreate();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (shouldReloadFeedAfterCreate(result)) {
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
FlutterNativeSplash.remove();
|
||||
@@ -254,26 +267,43 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
const SizedBox(width: 12),
|
||||
switch (_selectedChipIndex) {
|
||||
1 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreatePlanScreen.name),
|
||||
reload: () => context.read<PlansCubit>().loadPlans(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
2 when hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
|
||||
reload: () => context.read<RecipesCubit>().loadRecipesList(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
3 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
|
||||
reload: () => context.read<RecipesCubit>().loadRecipesList(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
3 when hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false});
|
||||
}
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}),
|
||||
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
4 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name);
|
||||
}
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name),
|
||||
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton),
|
||||
@@ -301,6 +331,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create screens pop `true` (diet/plan) or `'active'` (recipe) after a successful publish.
|
||||
bool shouldReloadFeedAfterCreate(Object? result) => result == true || result == 'active';
|
||||
|
||||
enum ViewableContentType {
|
||||
plans,
|
||||
posts,
|
||||
|
||||
@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
|
||||
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||
|
||||
/// Landscape / video slide aspect (width / height).
|
||||
/// Fallback while the real photo size is unknown, and for video-only.
|
||||
static const _horizontalAspectRatio = 16 / 9;
|
||||
|
||||
/// Portrait slide aspect (width / height).
|
||||
static const _verticalAspectRatio = 3 / 4;
|
||||
|
||||
bool _isCounterVisible = false;
|
||||
Timer? _counterHideTimer;
|
||||
|
||||
final Map<String, double> _aspectByUrl = {};
|
||||
final Map<String, ImageStream> _aspectStreams = {};
|
||||
final Map<String, ImageStreamListener> _aspectListeners = {};
|
||||
|
||||
bool get _hasVideo =>
|
||||
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||
|
||||
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
|
||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||
|
||||
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||
String get _postOrientation {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.first.orientation;
|
||||
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return 'horizontal';
|
||||
}
|
||||
|
||||
double _heightForAspect(double aspectRatio, double width) {
|
||||
if (aspectRatio <= 0) {
|
||||
return width / _horizontalAspectRatio;
|
||||
}
|
||||
return width / aspectRatio;
|
||||
}
|
||||
|
||||
double _heightForOrientation(String orientation, double width) {
|
||||
final aspectRatio = orientation == 'vertical'
|
||||
? _verticalAspectRatio
|
||||
: _horizontalAspectRatio;
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||
return _heightForAspect(aspectRatio, width);
|
||||
}
|
||||
|
||||
/// Window height follows the current photo's real size, not a fake 16:9 / 3:4 box.
|
||||
double _mediaHeight(double width) {
|
||||
final gallery = _gallery;
|
||||
if (gallery.isEmpty) {
|
||||
return _heightForOrientation(_postOrientation, width);
|
||||
}
|
||||
|
||||
final index = widget.currentGalleryIndex;
|
||||
final image = index < gallery.length ? gallery[index] : gallery.first;
|
||||
final aspect = _aspectByUrl[image.url];
|
||||
if (aspect != null && aspect > 0) {
|
||||
return _heightForAspect(aspect, width);
|
||||
}
|
||||
return _heightForOrientation(image.orientation, width);
|
||||
}
|
||||
|
||||
void _syncAspectListeners() {
|
||||
final urls = _gallery.map((image) => image.url).toSet();
|
||||
for (final url in _aspectStreams.keys.toList()) {
|
||||
if (urls.contains(url)) {
|
||||
continue;
|
||||
}
|
||||
final stream = _aspectStreams.remove(url);
|
||||
final listener = _aspectListeners.remove(url);
|
||||
if (stream != null && listener != null) {
|
||||
stream.removeListener(listener);
|
||||
}
|
||||
_aspectByUrl.remove(url);
|
||||
}
|
||||
|
||||
for (final url in urls) {
|
||||
if (_aspectStreams.containsKey(url)) {
|
||||
continue;
|
||||
}
|
||||
final stream = CachedNetworkImageProvider(
|
||||
url,
|
||||
).resolve(ImageConfiguration.empty);
|
||||
final listener = ImageStreamListener((info, _) {
|
||||
final aspect = info.image.width / info.image.height;
|
||||
if (!mounted || aspect <= 0 || _aspectByUrl[url] == aspect) {
|
||||
return;
|
||||
}
|
||||
setState(() => _aspectByUrl[url] = aspect);
|
||||
}, onError: (_, _) {});
|
||||
stream.addListener(listener);
|
||||
_aspectStreams[url] = stream;
|
||||
_aspectListeners[url] = listener;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncAspectListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_counterHideTimer?.cancel();
|
||||
for (final url in _aspectStreams.keys) {
|
||||
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_syncAspectListeners();
|
||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||
Widget _photo(String url, {required double width, double? height}) {
|
||||
return CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.center,
|
||||
width: width,
|
||||
height: height,
|
||||
imageUrl: url,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imageSlide(
|
||||
String itemUrl,
|
||||
int itemIndex, {
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
height: height,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
child: _photo(itemUrl, width: width, height: height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
viewportFraction: 1,
|
||||
height: height,
|
||||
enableInfiniteScroll: false,
|
||||
disableCenter: true,
|
||||
padEnds: false,
|
||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||
),
|
||||
),
|
||||
@@ -200,42 +279,59 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
return _buildMedia(width);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia(double width) {
|
||||
final gallery = _gallery;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||
final mediaHeight = _mediaHeight(width);
|
||||
|
||||
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||
final itemCount = _slideCount;
|
||||
return _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(
|
||||
gallery[itemIndex].url,
|
||||
itemIndex,
|
||||
width: width,
|
||||
height: mediaHeight,
|
||||
);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (gallery.length == 1) {
|
||||
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, 0),
|
||||
child: _photo(gallery.first.url, width: width),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasVideo) {
|
||||
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||
}
|
||||
|
||||
if (widget.imageUrl.isNotEmpty) {
|
||||
final image = CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: width,
|
||||
imageUrl: widget.imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
|
||||
final image = _photo(widget.imageUrl, width: width);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
child: widget.heroTag == null
|
||||
|
||||
@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'feed-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
_BlogPostActionsRow(
|
||||
post: post,
|
||||
isLiked: isLiked,
|
||||
@@ -199,6 +201,7 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
isAuthorAdmin: post.author.isAdmin,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
@@ -212,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool includeBottomSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -258,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/comments/presentation/comments_actions_bottom_sh
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
@override
|
||||
void didUpdateWidget(CommentsWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
if (widget.highlightedCommentId != null &&
|
||||
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
} else if (widget.comment != oldWidget.comment &&
|
||||
widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
}
|
||||
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
for (int i = 0; i < widget.comment.data.length; i++) ...[
|
||||
_comment(s, widget.comment.data[i]),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8),
|
||||
for (int j = 0; j < widget.comment.data[i].childrenComments.length; j++)
|
||||
Padding(padding: const EdgeInsets.only(left: 40), child: _comment(s, widget.comment.data[i].childrenComments[j], 16, true)),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
for (
|
||||
int j = 0;
|
||||
j < widget.comment.data[i].childrenComments.length;
|
||||
j++
|
||||
)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: _comment(
|
||||
s,
|
||||
widget.comment.data[i].childrenComments[j],
|
||||
16,
|
||||
true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
@@ -94,9 +110,18 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) {
|
||||
Widget _comment(
|
||||
S s,
|
||||
Comment comment, [
|
||||
double radius = 18,
|
||||
bool isChild = false,
|
||||
]) {
|
||||
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
|
||||
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
|
||||
final isMe =
|
||||
(sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded)
|
||||
.profile
|
||||
.slug ==
|
||||
comment.authorSlug;
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
onTap: () {
|
||||
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showInfoToast(s.text_was_copied);
|
||||
},
|
||||
edit: (String text) {
|
||||
context.read<CommentsCubit>().updateComment(id: comment.id, body: text);
|
||||
context.read<CommentsCubit>().updateComment(
|
||||
id: comment.id,
|
||||
body: text,
|
||||
);
|
||||
},
|
||||
delete: () {
|
||||
context.read<CommentsCubit>().deleteComment(id: comment.id);
|
||||
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id),
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: ComplainedContent.comment,
|
||||
id: comment.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
isMyComment: isMe,
|
||||
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: BoxDecoration(
|
||||
color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white,
|
||||
color:
|
||||
(widget.replyId == comment.id ||
|
||||
widget.highlightedCommentId == comment.id)
|
||||
? appTheme.blueA400.withValues(alpha: 0.1)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: comment.authorSlug,
|
||||
);
|
||||
GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: comment.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
comment.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
radius: radius,
|
||||
),
|
||||
),
|
||||
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
comment.authorName,
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: isChild ? MediaQuery.of(context).size.width * 0.72 : MediaQuery.of(context).size.width * 0.8,
|
||||
width: isChild
|
||||
? MediaQuery.of(context).size.width * 0.72
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Text(
|
||||
comment.body,
|
||||
style: TextStyle(height: 1, color: appTheme.black, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
comment.createdAt.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!isChild)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(comment.id);
|
||||
context.read<CommentsCubit>().setReplyId(
|
||||
comment.id,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
s.answer,
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/badge_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -17,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: consultant.slug,
|
||||
);
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0xFFF6F6F6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -36,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${consultant.ratingPlace}',
|
||||
style: TextStyle(fontSize: 20, color: appTheme.blueA400, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: appTheme.blueA400,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (consultant.photo.contains('https'))
|
||||
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png'))
|
||||
else
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
consultant.name,
|
||||
style: const TextStyle(height: 1, fontWeight: FontWeight.w500, fontSize: 15, color: Color(0xFF323F4B)),
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Рейтинг: ${consultant.totalScores}',
|
||||
style: TextStyle(color: appTheme.blueA400, height: 1, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -70,7 +93,10 @@ class ConsultantWidget extends StatelessWidget {
|
||||
),
|
||||
Ink(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: Colors.white),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/go_to_arrow.svg'),
|
||||
),
|
||||
],
|
||||
@@ -92,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
|
||||
children: [
|
||||
for (int i = 0; i < badges.length; i++) ...[
|
||||
badges[i].image.contains('svg')
|
||||
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
||||
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
|
||||
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
|
||||
? NetworkSvgPicture(
|
||||
url:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
|
||||
width: 28,
|
||||
height: 28,
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
|
||||
width: 28,
|
||||
height: 28,
|
||||
),
|
||||
i < badges.length - 1
|
||||
? const SizedBox(width: 8)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -80,6 +80,7 @@ class _DietsWidgetState extends State<DietsWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.diet.author.authorIsMe,
|
||||
isAuthorAdmin: widget.diet.author.isAdmin,
|
||||
path: 'diet/${widget.diet.slug}',
|
||||
id: widget.diet.id,
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': false}),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': false,
|
||||
},
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 426,
|
||||
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: BookmarkButton(
|
||||
onTap: () async {
|
||||
if (widget.plan.isFavorite) {
|
||||
await context.read<PlansCubit>().removePlanFromFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().removePlanFromFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
} else {
|
||||
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().addPlanToFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
}
|
||||
},
|
||||
color: appTheme.whiteA700,
|
||||
isFavorite: widget.plan.isFavorite,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
width: 40,
|
||||
height: 44,
|
||||
size: 19,
|
||||
@@ -85,9 +104,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.plan.author.authorIsMe,
|
||||
isAuthorAdmin: widget.plan.author.isAdmin,
|
||||
path: 'plan/${widget.plan.slug}',
|
||||
id: widget.plan.id,
|
||||
slug: widget.plan.slug,
|
||||
plan: widget.plan,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: widget.plan.author.authorSlug,
|
||||
),
|
||||
@@ -98,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
child: InfoRow(plan: widget.plan, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.plan),
|
||||
child: InfoRow(
|
||||
plan: widget.plan,
|
||||
itemsBackgroundColor: appTheme.whiteA700,
|
||||
disableDescriptionOfTypes: true,
|
||||
type: ProductType.plan,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -109,14 +135,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(widget.plan.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18)),
|
||||
child: Text(
|
||||
widget.plan.name,
|
||||
style: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.plan.details.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||
markdownText: widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details,
|
||||
textColor: appTheme.black,
|
||||
),
|
||||
),
|
||||
@@ -125,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
padding: EdgeInsets.only(left: 16, top: 8),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8B8B8B),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -134,7 +173,10 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.plan.author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
@@ -145,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.plan.author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Row(
|
||||
@@ -153,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.gray50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 10,
|
||||
),
|
||||
children: [
|
||||
Text('${widget.plan.author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textXSSemibold.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${widget.plan.author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textXSSemibold.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.greenA700),
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.greenA700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(widget.plan.author.authorName, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)),
|
||||
child: Text(
|
||||
widget.plan.author.authorName,
|
||||
style: TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -179,12 +244,21 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: widget.plan.wasLikedByMe ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: widget.plan.wasLikedByMe
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: widget.plan.totalLikes.toString(),
|
||||
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null,
|
||||
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null,
|
||||
iconColor: widget.plan.wasLikedByMe
|
||||
? appTheme.blueA400
|
||||
: null,
|
||||
backgroundColor: widget.plan.wasLikedByMe
|
||||
? appTheme.transparentBlue
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<PlansCubit>().likePlan(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().likePlan(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -192,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
icon: Icons.chat_bubble_outline,
|
||||
text: widget.plan.totalComments.toString(),
|
||||
onTap: () {
|
||||
return GoRouter.of(
|
||||
context,
|
||||
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
|
||||
return GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': true,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -204,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final result = await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
switch (result.status) {
|
||||
|
||||
@@ -115,6 +115,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.recipe.author.authorIsMe,
|
||||
isAuthorAdmin: widget.recipe.author.isAdmin,
|
||||
path: 'recipe/${widget.recipe.slug}',
|
||||
slug: widget.recipe.slug,
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
const _passKeyLoginPath = 'login_with_pass_key';
|
||||
const _contentReadyProgress = 40;
|
||||
|
||||
@visibleForTesting
|
||||
bool shouldHideGlossaryLoader({required String url, required int progress}) {
|
||||
if (url.isEmpty || url.contains(_passKeyLoginPath)) {
|
||||
return false;
|
||||
}
|
||||
return progress >= _contentReadyProgress;
|
||||
}
|
||||
|
||||
class GlossaryScreen extends StatefulWidget {
|
||||
const GlossaryScreen({super.key, required this.passKey});
|
||||
final String passKey;
|
||||
@@ -12,6 +24,8 @@ class GlossaryScreen extends StatefulWidget {
|
||||
|
||||
class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||
late final WebViewController _controller;
|
||||
bool _isLoading = true;
|
||||
String _currentUrl = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -19,14 +33,47 @@ class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
_currentUrl = url;
|
||||
},
|
||||
onProgress: (progress) {
|
||||
if (shouldHideGlossaryLoader(url: _currentUrl, progress: progress)) {
|
||||
_hideLoader();
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (shouldHideGlossaryLoader(url: url, progress: 100)) {
|
||||
_hideLoader();
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
|
||||
}
|
||||
|
||||
void _hideLoader() {
|
||||
if (!_isLoading || !mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Глоссарий')),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_isLoading)
|
||||
ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: const SizedBox.expand(child: Loader()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:developer';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
|
||||
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
final NotificationSettingsRepository _notificationSettingsRepository;
|
||||
int _alertSeq = 0;
|
||||
|
||||
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
|
||||
: _notificationSettingsRepository = notificationSettingsRepository,
|
||||
@@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
} catch (e, stackTrace) {
|
||||
await reportFrom(e, stackTrace, this);
|
||||
log('Error updating notification settings: $e', stackTrace: stackTrace);
|
||||
final current = state;
|
||||
if (current is NotificationSettingsStateLoaded) {
|
||||
emit(
|
||||
NotificationSettingsStateLoaded(
|
||||
notificationSettings: current.notificationSettings,
|
||||
alertMessage: notificationSettingsUpdateAlertMessage(e),
|
||||
alertId: ++_alertSeq,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(NotificationSettingsStateError(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ class NotificationSettingsStateLoading extends NotificationSettingsState {
|
||||
|
||||
class NotificationSettingsStateLoaded extends NotificationSettingsState {
|
||||
final NotificationSettingsModel notificationSettings;
|
||||
final String? alertMessage;
|
||||
final int? alertId;
|
||||
|
||||
const NotificationSettingsStateLoaded({required this.notificationSettings});
|
||||
const NotificationSettingsStateLoaded({
|
||||
required this.notificationSettings,
|
||||
this.alertMessage,
|
||||
this.alertId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [notificationSettings];
|
||||
List<Object?> get props => [notificationSettings, alertMessage, alertId];
|
||||
}
|
||||
|
||||
class NotificationSettingsStateError extends NotificationSettingsState {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
const notificationSettingsSaveFailedMessage =
|
||||
'Не удалось сохранить настройки уведомлений';
|
||||
|
||||
|
||||
String notificationSettingsUpdateAlertMessage(Object error) {
|
||||
final validationMessage = _validationMessage(error);
|
||||
if (validationMessage != null) return validationMessage;
|
||||
return notificationSettingsSaveFailedMessage;
|
||||
}
|
||||
|
||||
String? _validationMessage(Object error) {
|
||||
if (error is! DioException || error.response?.statusCode != 422) return null;
|
||||
|
||||
final data = error.response?.data;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final errors = data['errors'];
|
||||
if (errors is Map) {
|
||||
for (final value in errors.values) {
|
||||
final text = _firstErrorText(value);
|
||||
if (text != null) return text;
|
||||
}
|
||||
}
|
||||
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) return message.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstErrorText(Object? value) {
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final text = item.toString().trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
return null;
|
||||
}
|
||||
@@ -27,7 +27,37 @@ class NotificationsSettingsContent extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.notifications)),
|
||||
body: BlocBuilder<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
body: BlocConsumer<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
listenWhen: (previous, current) {
|
||||
if (current is! NotificationSettingsStateLoaded || current.alertId == null) {
|
||||
return false;
|
||||
}
|
||||
final previousId = previous is NotificationSettingsStateLoaded ? previous.alertId : null;
|
||||
return current.alertId != previousId;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is! NotificationSettingsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final message = state.alertMessage;
|
||||
if (message == null || message.isEmpty) {
|
||||
return;
|
||||
}
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is NotificationSettingsStateLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,16 +19,20 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.profile.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.profile.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.profile.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.profile.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.read<BlockedUsersCubit>().unblockUser(widget.profile.slug);
|
||||
context.read<BlockedUsersCubit>().unblockUser(
|
||||
widget.profile.slug,
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F2F6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId);
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.chosenPlanId);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
|
||||
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
|
||||
child: InfoByOrderScreenContent(
|
||||
successfulPayment: successfulPayment,
|
||||
planId: planId,
|
||||
customerSlug: customerSlug,
|
||||
isConsultantView: isConsultantView,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InfoByOrderScreenContent extends StatefulWidget {
|
||||
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
|
||||
const InfoByOrderScreenContent({
|
||||
super.key,
|
||||
required this.planId,
|
||||
required this.successfulPayment,
|
||||
this.customerSlug,
|
||||
this.isConsultantView = false,
|
||||
});
|
||||
final int planId;
|
||||
final bool? successfulPayment;
|
||||
final String? customerSlug;
|
||||
final bool isConsultantView;
|
||||
@override
|
||||
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
|
||||
}
|
||||
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone && (widget.customerSlug != null || widget.isConsultantView)) ...[
|
||||
CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0),
|
||||
],
|
||||
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
|
||||
const SizedBox(height: 8),
|
||||
AuthorCard(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
@@ -14,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ConsultationWidget extends StatelessWidget {
|
||||
const ConsultationWidget({super.key, required this.consultationModel, required this.dietName});
|
||||
const ConsultationWidget({
|
||||
super.key,
|
||||
required this.consultationModel,
|
||||
required this.dietName,
|
||||
});
|
||||
final ConsultationModel consultationModel;
|
||||
final String dietName;
|
||||
@override
|
||||
@@ -33,7 +38,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -43,18 +53,24 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Старт: ${consultationModel.userName}',
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
@@ -62,7 +78,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -72,25 +93,32 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (consultationModel.personalDietId != null)
|
||||
CustomOutlinedButton(
|
||||
text: s.changeDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -100,7 +128,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
userName: consultationModel.userName,
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
@@ -134,8 +166,13 @@ class ConsultationWidget extends StatelessWidget {
|
||||
CustomOutlinedButton(
|
||||
text: s.suggestDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -146,7 +183,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
showBottomSheetWithBlur(
|
||||
@@ -174,7 +215,7 @@ class ConsultationWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -204,35 +245,33 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.toOffer,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: widget.onTap,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -249,10 +288,12 @@ class SuggestionSuccessBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
|
||||
State<SuggestionSuccessBottomSheet> createState() =>
|
||||
_SuggestionSuccessBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
|
||||
class _SuggestionSuccessBottomSheet
|
||||
extends State<SuggestionSuccessBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'«${widget.dietName}» предложена пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -308,10 +347,12 @@ class SuggestionFailureBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
|
||||
State<SuggestionFailureBottomSheet> createState() =>
|
||||
_SuggestionFailureBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
|
||||
class _SuggestionFailureBottomSheet
|
||||
extends State<SuggestionFailureBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,12 +7,21 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
final PlanRepository planRepository;
|
||||
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
|
||||
final ProfileRepository profileRepository;
|
||||
final DietRepository dietRepository;
|
||||
|
||||
AboutPlanCubit({
|
||||
required this.planRepository,
|
||||
required this.profileRepository,
|
||||
required this.dietRepository,
|
||||
}) : super(AboutPlanState.initial());
|
||||
|
||||
Future<void> getPlan({required String slug, int? index}) async {
|
||||
try {
|
||||
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
return (null, '');
|
||||
}
|
||||
|
||||
/// Активная публичная диета (без привязки к плану) в статусах inited/started.
|
||||
Future<ChosenDietModel?> getActivePublicDiet() async {
|
||||
try {
|
||||
final chosenDiets = await profileRepository.getChosenDiets();
|
||||
final lastChosenDiet = chosenDiets.firstOrNull;
|
||||
if (lastChosenDiet == null) {
|
||||
return null;
|
||||
}
|
||||
final isActive = ['inited', 'started'].contains(lastChosenDiet.state);
|
||||
final isPublic = lastChosenDiet.chosenPlanId == 0;
|
||||
if (isActive && isPublic && lastChosenDiet.dietId != null) {
|
||||
return lastChosenDiet;
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> cancelPublicDiet(int dietId) async {
|
||||
try {
|
||||
await dietRepository.cancelDiet(dietId);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _getOptionNameFromIndex(int index) => switch (index) {
|
||||
1 => 'options2',
|
||||
2 => 'options3',
|
||||
|
||||
@@ -17,8 +17,10 @@ import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||
}
|
||||
|
||||
Future<void> _onOrderPressed(BuildContext context) async {
|
||||
final cubit = context.read<AboutPlanCubit>();
|
||||
final publicDiet = await cubit.getActivePublicDiet();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (publicDiet != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => ConfirmStartPlanBottomSheet(
|
||||
onConfirm: () => _submitOrder(context, cubit, publicDietId: publicDiet.dietId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _submitOrder(context, cubit);
|
||||
}
|
||||
|
||||
Future<void> _submitOrder(BuildContext context, AboutPlanCubit cubit, {int? publicDietId}) async {
|
||||
final s = S.of(context);
|
||||
if (publicDietId != null) {
|
||||
final cancelled = await cubit.cancelPublicDiet(publicDietId);
|
||||
if (!cancelled) {
|
||||
showErrorToast('Не удалось отменить диету');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final (chosenPlan, error) = await cubit.chosePlan();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -147,7 +194,11 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
@@ -182,20 +233,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: appTheme.greenA700,
|
||||
onPressed: () async {
|
||||
final (chosenPlan, error) = await context.read<AboutPlanCubit>().chosePlan();
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
},
|
||||
onPressed: () => _onOrderPressed(context),
|
||||
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
|
||||
),
|
||||
),
|
||||
@@ -339,7 +377,11 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConfirmStartPlanBottomSheet extends StatelessWidget {
|
||||
const ConfirmStartPlanBottomSheet({super.key, required this.onConfirm});
|
||||
|
||||
final Future<void> Function() onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
child: Text(
|
||||
'Вы уверены, что хотите начать прохождение плана?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).maybePop();
|
||||
await onConfirm();
|
||||
},
|
||||
height: 60,
|
||||
text: s.confirm,
|
||||
buttonTextStyle: TextStyles.textLSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ abstract class ChosenDietsPaginationModel with _$ChosenDietsPaginationModel {
|
||||
abstract class ChosenDietModel with _$ChosenDietModel {
|
||||
factory ChosenDietModel({
|
||||
@JsonKey(name: 'id') required int chosenDietID,
|
||||
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||
@JsonKey(name: 'chosen_plan_id', fromJson: _parseChosenPlanId) required int chosenPlanId,
|
||||
@JsonKey() required String state,
|
||||
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
|
||||
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
|
||||
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
|
||||
if (json == null) return null;
|
||||
return json['id'] as int?;
|
||||
}
|
||||
|
||||
int _parseChosenPlanId(dynamic json) {
|
||||
if (json == null) return 0;
|
||||
if (json is num) return json.toInt();
|
||||
return int.tryParse(json.toString()) ?? 0;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpandablePageView extends StatefulWidget {
|
||||
const ExpandablePageView({
|
||||
super.key,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.onPageChanged,
|
||||
});
|
||||
|
||||
final int itemCount;
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
|
||||
@override
|
||||
State<ExpandablePageView> createState() => _ExpandablePageViewState();
|
||||
}
|
||||
|
||||
class _ExpandablePageViewState extends State<ExpandablePageView> {
|
||||
late List<double> _heights;
|
||||
int _currentPage = 0;
|
||||
|
||||
double get _currentHeight {
|
||||
if (_heights.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final page = _currentPage.clamp(0, _heights.length - 1);
|
||||
return _heights[page];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExpandablePageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.itemCount != oldWidget.itemCount) {
|
||||
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||
if (widget.itemCount == 0) {
|
||||
_currentPage = 0;
|
||||
} else {
|
||||
_currentPage = _currentPage.clamp(0, widget.itemCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: _currentHeight,
|
||||
child: PageView.builder(
|
||||
itemCount: widget.itemCount,
|
||||
onPageChanged: (int index) {
|
||||
setState(() {
|
||||
_currentPage = index;
|
||||
});
|
||||
widget.onPageChanged?.call(index);
|
||||
},
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: 0,
|
||||
maxHeight: double.infinity,
|
||||
child: _SizeReportingWidget(
|
||||
onSizeChange: (Size size) {
|
||||
if (index >= _heights.length ||
|
||||
_heights[index] == size.height) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_heights[index] = size.height;
|
||||
});
|
||||
},
|
||||
child: widget.itemBuilder(context, index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SizeReportingWidget extends StatefulWidget {
|
||||
const _SizeReportingWidget({required this.child, required this.onSizeChange});
|
||||
|
||||
final Widget child;
|
||||
final ValueChanged<Size> onSizeChange;
|
||||
|
||||
@override
|
||||
State<_SizeReportingWidget> createState() => _SizeReportingWidgetState();
|
||||
}
|
||||
|
||||
class _SizeReportingWidgetState extends State<_SizeReportingWidget> {
|
||||
Size? _oldSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
void _notifySize() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final size = context.size;
|
||||
if (size != null && _oldSize != size) {
|
||||
_oldSize = size;
|
||||
widget.onSizeChange(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
totalLikes = post.totalLikes;
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -79,44 +84,17 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
post.author.authorProfileIcon.contains('https')
|
||||
? post.author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl +
|
||||
post.author.authorProfileIcon,
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
isAuthorAdmin: post.author.isAdmin || state.profile.roles.contains('admin'),
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
@@ -166,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (hasMedia && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/profile/models/sub_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/sub_action_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,10 +9,7 @@ import 'package:go_router/go_router.dart';
|
||||
class SubWidget extends StatefulWidget {
|
||||
final SubModel subModel;
|
||||
|
||||
const SubWidget({
|
||||
super.key,
|
||||
required this.subModel,
|
||||
});
|
||||
const SubWidget({super.key, required this.subModel});
|
||||
|
||||
@override
|
||||
State<SubWidget> createState() => _SubWidgetState();
|
||||
@@ -21,16 +19,20 @@ class _SubWidgetState extends State<SubWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.subModel.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.subModel.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.subModel.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.subModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -62,7 +64,9 @@ class _SubWidgetState extends State<SubWidget> {
|
||||
),
|
||||
const Spacer(),
|
||||
SubActionButtonWidget(
|
||||
subActionType: widget.subModel.isEntitySubscribedByMe ? SubActionType.sendMessage : SubActionType.subscribe,
|
||||
subActionType: widget.subModel.isEntitySubscribedByMe
|
||||
? SubActionType.sendMessage
|
||||
: SubActionType.subscribe,
|
||||
slug: widget.subModel.slug,
|
||||
name: widget.subModel.name,
|
||||
porfileIcon: widget.subModel.profilePhotoUrl,
|
||||
|
||||
@@ -223,6 +223,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: recipeState.recipe.author.authorIsMe,
|
||||
isAuthorAdmin: recipeState.recipe.author.isAdmin,
|
||||
path: 'recipe/${widget.recipeSlug}',
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
slug: widget.recipeSlug,
|
||||
|
||||
@@ -221,49 +221,51 @@ class _GeneralInfoScreenState extends State<GeneralInfoScreen> {
|
||||
}
|
||||
|
||||
Widget _saveButton() {
|
||||
return isLoading
|
||||
? const Center(child: Loader())
|
||||
: InkWell(
|
||||
onTap: () async {
|
||||
final hasError = _checkFields();
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
await widget.settingsEntity.settingsCubit.updateProfileDetails(
|
||||
profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith(
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
birthdate: selectedDate.toString(),
|
||||
gender: gender.value,
|
||||
country: countryController.text,
|
||||
city: cityController.text,
|
||||
lang: languageController.text,
|
||||
),
|
||||
);
|
||||
await widget.settingsEntity.settingsCubit.updateProfileSettings(
|
||||
profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith(
|
||||
isPublicProfile: isPublicProfile.value,
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (isLoading) {
|
||||
return const SizedBox(height: 52, child: Loader());
|
||||
}
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
final hasError = _checkFields();
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
await widget.settingsEntity.settingsCubit.updateProfileDetails(
|
||||
profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith(
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
birthdate: selectedDate.toString(),
|
||||
gender: gender.value,
|
||||
country: countryController.text,
|
||||
city: cityController.text,
|
||||
lang: languageController.text,
|
||||
),
|
||||
);
|
||||
await widget.settingsEntity.settingsCubit.updateProfileSettings(
|
||||
profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith(
|
||||
isPublicProfile: isPublicProfile.value,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapshot);
|
||||
typedef VideoCallManagerListener =
|
||||
void Function(VideoCallManagerSnapshot snapshot);
|
||||
|
||||
class VideoCallManager {
|
||||
VideoCallManager({
|
||||
@@ -158,9 +159,7 @@ class VideoCallManager {
|
||||
return lk.CameraCaptureOptions(
|
||||
cameraPosition:
|
||||
cameraPosition ??
|
||||
(_useFrontCamera
|
||||
? lk.CameraPosition.front
|
||||
: lk.CameraPosition.back),
|
||||
(_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back),
|
||||
params: lk.VideoParametersPresets.h1080_169,
|
||||
);
|
||||
}
|
||||
@@ -286,10 +285,11 @@ class VideoCallManager {
|
||||
if (_liveKitDisposed) return false;
|
||||
|
||||
_callStageEntered = true;
|
||||
await _startCallAudioSession();
|
||||
if (_liveKitDisposed || !_callStageEntered) return false;
|
||||
await _publishLocalMedia();
|
||||
if (_liveKitDisposed || !_callStageEntered) return false;
|
||||
await _startForegroundIfPossible();
|
||||
await _startCallAudioSession();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_emitSnapshot(
|
||||
@@ -352,8 +352,7 @@ class VideoCallManager {
|
||||
return;
|
||||
}
|
||||
final room = _room;
|
||||
if (room == null ||
|
||||
room.connectionState != lk.ConnectionState.connected) {
|
||||
if (room == null || room.connectionState != lk.ConnectionState.connected) {
|
||||
return;
|
||||
}
|
||||
if (room.remoteParticipants.isEmpty) return;
|
||||
@@ -469,13 +468,20 @@ class VideoCallManager {
|
||||
if (next) {
|
||||
final granted = await _ensureMicrophonePermission();
|
||||
if (!granted) return;
|
||||
await _startCallAudioSession();
|
||||
}
|
||||
final participant = _room!.localParticipant;
|
||||
if (participant == null) return;
|
||||
try {
|
||||
await participant.setMicrophoneEnabled(
|
||||
next,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
||||
_lastMutedMicTotalEnergy = null;
|
||||
await _room!.localParticipant?.setMicrophoneEnabled(
|
||||
next,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleVideo(bool currentEnabled) async {
|
||||
@@ -536,9 +542,7 @@ class VideoCallManager {
|
||||
try {
|
||||
await lp.setCameraEnabled(
|
||||
true,
|
||||
cameraCaptureOptions: cameraCaptureOptions(
|
||||
cameraPosition: newPosition,
|
||||
),
|
||||
cameraCaptureOptions: cameraCaptureOptions(cameraPosition: newPosition),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -663,6 +667,15 @@ class VideoCallManager {
|
||||
|
||||
Future<void> _onAppResumed() async {
|
||||
if (!isActiveCall) return;
|
||||
await _startCallAudioSession();
|
||||
if (_snapshot.micEnabled) {
|
||||
try {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _resumeVideoAfterForeground();
|
||||
}
|
||||
|
||||
@@ -670,12 +683,10 @@ class VideoCallManager {
|
||||
if (!isActiveCall) return;
|
||||
final ctx = _navigatorContext;
|
||||
final s = ctx != null ? S.of(ctx) : null;
|
||||
final title =
|
||||
resolvedRemoteDisplayName(
|
||||
s?.video_call_counterpart_default ?? 'Call',
|
||||
);
|
||||
final body =
|
||||
s?.video_call_notification_in_progress ?? 'Call in progress';
|
||||
final title = resolvedRemoteDisplayName(
|
||||
s?.video_call_counterpart_default ?? 'Call',
|
||||
);
|
||||
final body = s?.video_call_notification_in_progress ?? 'Call in progress';
|
||||
final endLabel = s?.video_call_notification_end ?? 'End call';
|
||||
await _foreground.startOngoingCall(
|
||||
conversationUuid: _snapshot.conversationUuid,
|
||||
@@ -739,11 +750,7 @@ class VideoCallManager {
|
||||
_roomConnectFuture = null;
|
||||
_clearPreparedConnectionCredentials();
|
||||
_callAcceptNotifiedConversationUuids.clear();
|
||||
_emitSnapshot(
|
||||
_snapshot.copyWith(
|
||||
counterpartPresentInLobby: false,
|
||||
),
|
||||
);
|
||||
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
|
||||
}
|
||||
|
||||
void _clearPreparedConnectionCredentials() {
|
||||
@@ -925,10 +932,13 @@ class VideoCallManager {
|
||||
_preparedWsUrl = tokenResponse.wsUrl;
|
||||
_preparedToken = tokenResponse.token;
|
||||
|
||||
await room.prepareConnection(
|
||||
tokenResponse.wsUrl,
|
||||
tokenResponse.token,
|
||||
);
|
||||
// Warmup must not keep the enter button disabled. Joining the room
|
||||
// connects with the token even if prepareConnection hangs or fails.
|
||||
try {
|
||||
await room
|
||||
.prepareConnection(tokenResponse.wsUrl, tokenResponse.token)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
} catch (_) {}
|
||||
if (_liveKitDisposed) return;
|
||||
|
||||
_emitSnapshot(
|
||||
@@ -977,8 +987,7 @@ class VideoCallManager {
|
||||
_syncRemoteParticipantNameFromRoom();
|
||||
|
||||
_syncPoorConnectionOverlay(
|
||||
room.localParticipant?.connectionQuality ??
|
||||
lk.ConnectionQuality.unknown,
|
||||
room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1002,8 +1011,6 @@ class VideoCallManager {
|
||||
status.isPermanentlyDenied || status.isRestricted;
|
||||
|
||||
Future<bool> _ensureCameraPermission() async {
|
||||
final ctx = _navigatorContext;
|
||||
if (ctx == null) return false;
|
||||
var status = await Permission.camera.status;
|
||||
if (status.isGranted) return true;
|
||||
status = await Permission.camera.request();
|
||||
@@ -1023,8 +1030,6 @@ class VideoCallManager {
|
||||
}
|
||||
|
||||
Future<bool> _ensureMicrophonePermission() async {
|
||||
final ctx = _navigatorContext;
|
||||
if (ctx == null) return false;
|
||||
var status = await Permission.microphone.status;
|
||||
if (status.isGranted) return true;
|
||||
status = await Permission.microphone.request();
|
||||
@@ -1136,4 +1141,4 @@ class VideoCallManager {
|
||||
await _audioSession.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,9 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
connectPhase: snapshot.connectPhase,
|
||||
linkIssue: snapshot.linkIssue,
|
||||
callStageEntered: resetCallStage ? false : state.callStageEntered,
|
||||
uiMode:
|
||||
_isCallSessionInactive(snapshot)
|
||||
? VideoCallUiMode.hidden
|
||||
: state.uiMode,
|
||||
uiMode: _isCallSessionInactive(snapshot)
|
||||
? VideoCallUiMode.hidden
|
||||
: state.uiMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,8 +363,6 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
if (!granted || isClosed) return;
|
||||
|
||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(micEnabled: true));
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
|
||||
@@ -1,43 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
||||
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
|
||||
|
||||
/// Configures and owns the platform audio session for an active VoIP call.
|
||||
class VideoCallAudioSessionCoordinator {
|
||||
VideoCallAudioSessionCoordinator({
|
||||
VideoCallAudioSessionFactory? audioSessionFactory,
|
||||
bool? isIos,
|
||||
IosCallAudioSessionHandler? iosCallAudioSession,
|
||||
}) : _audioSessionFactory =
|
||||
audioSessionFactory ?? (() => AudioSession.instance);
|
||||
audioSessionFactory ?? (() => AudioSession.instance),
|
||||
_isIos = isIos ?? Platform.isIOS,
|
||||
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession;
|
||||
|
||||
static const MethodChannel _iosChannel = MethodChannel(
|
||||
'com.eatvioapp/call_audio_session',
|
||||
);
|
||||
|
||||
final VideoCallAudioSessionFactory _audioSessionFactory;
|
||||
final bool _isIos;
|
||||
final IosCallAudioSessionHandler _iosCallAudioSession;
|
||||
|
||||
static final AudioSessionConfiguration callConfiguration =
|
||||
AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
androidAudioAttributes: const AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.speech,
|
||||
flags: AndroidAudioFlags.none,
|
||||
usage: AndroidAudioUsage.voiceCommunication,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
androidAudioAttributes: const AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.speech,
|
||||
flags: AndroidAudioFlags.none,
|
||||
usage: AndroidAudioUsage.voiceCommunication,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
|
||||
AudioSession? _session;
|
||||
bool _configured = false;
|
||||
bool _iosActive = false;
|
||||
|
||||
bool get isActive => _session != null;
|
||||
bool get isActive => _isIos ? _iosActive : _session != null;
|
||||
|
||||
static Future<void> _invokeIosCallAudioSession(String method) {
|
||||
return _iosChannel.invokeMethod<void>(method);
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (_isIos) {
|
||||
await _iosCallAudioSession('activate');
|
||||
_iosActive = true;
|
||||
return;
|
||||
}
|
||||
final session = await _audioSessionFactory();
|
||||
_session = session;
|
||||
if (!_configured) {
|
||||
@@ -48,6 +70,15 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
|
||||
Future<bool> reactivate() async {
|
||||
if (_isIos) {
|
||||
try {
|
||||
await _iosCallAudioSession('activate');
|
||||
_iosActive = true;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
final session = _session;
|
||||
if (session == null) return false;
|
||||
try {
|
||||
@@ -58,6 +89,13 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (_isIos) {
|
||||
_iosActive = false;
|
||||
try {
|
||||
await _iosCallAudioSession('deactivate');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session == null) return;
|
||||
|
||||
@@ -92,17 +92,9 @@ class VideoCallCallKitCoordinator {
|
||||
onResumed: _onAndroidAppResumed,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(_androidResumeObserver!);
|
||||
_androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall);
|
||||
await FlutterCallkitIncoming.requestNotificationPermission(<
|
||||
String,
|
||||
dynamic
|
||||
>{
|
||||
'title': 'Уведомления',
|
||||
'rationaleMessagePermission':
|
||||
'Разрешите уведомления, чтобы получать входящие видеозвонки.',
|
||||
'postNotificationMessageRequired':
|
||||
'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.',
|
||||
});
|
||||
_androidCallPushChannel.setMethodCallHandler(
|
||||
_onAndroidCallPushMethodCall,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_pollNativePendingAcceptAfterResume());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
|
||||
enum VideoCallChatSignalKind { none, started, ended }
|
||||
|
||||
/// Detects a video-call system message.
|
||||
///
|
||||
/// The realtime `ChatMessageUpdated` payload does not include `type`, so an
|
||||
/// open app has to recognize the system text. Messages that already have
|
||||
/// another type are ignored.
|
||||
abstract final class VideoCallChatSignal {
|
||||
static final RegExp _startedRu = RegExp(
|
||||
r'начал\(а\) видеозвонок\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _startedEn = RegExp(
|
||||
r'started a video call\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _endedRu = RegExp(
|
||||
r'завершил\(а\) видеозвонок\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _endedEn = RegExp(
|
||||
r'ended a video call\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _callerRu = RegExp(
|
||||
r'^(.*) начал\(а\) видеозвонок\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _callerEn = RegExp(
|
||||
r'^(.*) started a video call\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static VideoCallChatSignalKind kindOf(ChatMessageModel message) {
|
||||
if (message.type == ChatMessageType.videoCallEnded) {
|
||||
return VideoCallChatSignalKind.ended;
|
||||
}
|
||||
if (message.type == ChatMessageType.videoCallStarted) {
|
||||
return VideoCallChatSignalKind.started;
|
||||
}
|
||||
if (message.type != null) return VideoCallChatSignalKind.none;
|
||||
|
||||
final text = message.data.trim();
|
||||
if (_endedRu.hasMatch(text) || _endedEn.hasMatch(text)) {
|
||||
return VideoCallChatSignalKind.ended;
|
||||
}
|
||||
if (_startedRu.hasMatch(text) || _startedEn.hasMatch(text)) {
|
||||
return VideoCallChatSignalKind.started;
|
||||
}
|
||||
return VideoCallChatSignalKind.none;
|
||||
}
|
||||
|
||||
static VideoCallPushPayload? incomingPayload({
|
||||
required ChatMessageModel message,
|
||||
String? callerName,
|
||||
String? callerAvatarUrl,
|
||||
}) {
|
||||
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
|
||||
final conversationUuid = message.roomId?.trim();
|
||||
if (conversationUuid == null || conversationUuid.isEmpty) return null;
|
||||
|
||||
final resolvedName = callerName?.trim();
|
||||
final avatar = callerAvatarUrl?.trim();
|
||||
return VideoCallPushPayload(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: resolvedName != null && resolvedName.isNotEmpty
|
||||
? resolvedName
|
||||
: callerNameFromBody(message.data),
|
||||
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
|
||||
messageId: message.id > 0 ? message.id : null,
|
||||
);
|
||||
}
|
||||
|
||||
static String? callerNameFromBody(String body) {
|
||||
final text = body.trim();
|
||||
for (final pattern in [_callerRu, _callerEn]) {
|
||||
final name = pattern.firstMatch(text)?.group(1)?.trim();
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
@@ -6,18 +9,25 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
static const String _androidAccentColor = '#2787F5';
|
||||
|
||||
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
|
||||
final callerName =
|
||||
payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
|
||||
final callId = Platform.isIOS
|
||||
? VideoCallKitId.iosUuid(
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
) ??
|
||||
payload.conversationUuid
|
||||
: payload.conversationUuid;
|
||||
|
||||
final params = CallKitParams(
|
||||
id: payload.conversationUuid,
|
||||
id: callId,
|
||||
nameCaller: callerName,
|
||||
appName: 'Итвио',
|
||||
avatar: payload.counterpartAvatarUrl,
|
||||
handle: payload.conversationUuid,
|
||||
type: 0,
|
||||
type: 1,
|
||||
duration: 45000,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': payload.conversationUuid,
|
||||
|
||||
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
|
||||
///
|
||||
/// The conversation uuid stays in the call payload for navigation. The CallKit
|
||||
/// id is derived from that uuid plus the chat message id, so one ring is one
|
||||
/// call and the next ring in the same chat still appears. Android notifications
|
||||
/// keep the conversation uuid: the same id updates one incoming notification.
|
||||
abstract final class VideoCallKitId {
|
||||
static final RegExp _uuidPrefix = RegExp(
|
||||
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
||||
);
|
||||
|
||||
static String? iosUuid({
|
||||
required String conversationUuid,
|
||||
required int? messageId,
|
||||
}) {
|
||||
if (messageId == null || messageId <= 0) return null;
|
||||
final prefix = _uuidPrefix.firstMatch(conversationUuid.trim())?.group(1);
|
||||
if (prefix == null) return null;
|
||||
final suffix = messageId.toRadixString(16).padLeft(12, '0');
|
||||
if (suffix.length != 12) return null;
|
||||
return '$prefix$suffix'.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ class VideoCallPushPayload {
|
||||
this.counterpartAvatarUrl,
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
this.messageId,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
@@ -12,6 +13,7 @@ class VideoCallPushPayload {
|
||||
final String? counterpartAvatarUrl;
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
final int? messageId;
|
||||
|
||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!isIncomingVideoCall(data)) return null;
|
||||
@@ -49,6 +51,7 @@ class VideoCallPushPayload {
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
messageId: _messageId(data),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +135,8 @@ class VideoCallPushPayload {
|
||||
final uri = Uri.tryParse(actionUrl);
|
||||
if (uri == null) return null;
|
||||
|
||||
final fromQuery = uri.queryParameters['uuid'] ??
|
||||
final fromQuery =
|
||||
uri.queryParameters['uuid'] ??
|
||||
uri.queryParameters['conversationUuid'] ??
|
||||
uri.queryParameters['conversation_uuid'];
|
||||
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
|
||||
@@ -167,14 +171,22 @@ class VideoCallPushPayload {
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _messageId(Map<String, dynamic> data) {
|
||||
final value = data['message_id'] ?? data['messageId'];
|
||||
if (value is int) return value > 0 ? value : null;
|
||||
if (value is String) {
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed != null && parsed > 0) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isTruthy(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
final normalized = value.toString().trim().toLowerCase();
|
||||
return normalized == '1' ||
|
||||
normalized == 'true' ||
|
||||
normalized == 'yes';
|
||||
return normalized == '1' || normalized == 'true' || normalized == 'yes';
|
||||
}
|
||||
|
||||
static String? _resolveAvatarUrl(String? raw) {
|
||||
|
||||
@@ -44,82 +44,112 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
|
||||
),
|
||||
Positioned.fill(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 80),
|
||||
SvgPicture.asset('assets/images/logo_welcome.svg'),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Добро пожаловать!',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
key: AppKeys.welcomeScreenButton,
|
||||
onTap: () async {
|
||||
final sharedPreferences = sl<Prefs>();
|
||||
await sharedPreferences.setBool(PrefsKeys.welcomeScreenWasShowed, true);
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
goRouter.go('/feed-screen');
|
||||
} else {
|
||||
goRouter.go('/auth-screen');
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Начать пользоваться',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: Colors.white,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1);
|
||||
final topGap = textScale > 1.2 ? 24.0 : 80.0;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 16,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: topGap),
|
||||
SvgPicture.asset('assets/images/logo_welcome.svg'),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Добро пожаловать!',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
key: AppKeys.welcomeScreenButton,
|
||||
onTap: () async {
|
||||
final sharedPreferences = sl<Prefs>();
|
||||
await sharedPreferences.setBool(
|
||||
PrefsKeys.welcomeScreenWasShowed,
|
||||
true,
|
||||
);
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
goRouter.go('/feed-screen');
|
||||
} else {
|
||||
goRouter.go('/auth-screen');
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 52,
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Начать пользоваться',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -137,6 +137,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Средние показатели давления:",
|
||||
),
|
||||
"back_to_chat": MessageLookupByLibrary.simpleMessage("Вернуться в чат"),
|
||||
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Минимальный размер изображения — 500×500 px",
|
||||
),
|
||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||
"calls_for_bullying": MessageLookupByLibrary.simpleMessage(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user