Compare commits

...

57 Commits

Author SHA1 Message Date
misha
866ea2fe3b Merge remote-tracking branch 'upstream/main' into issue-1062 2026-10-08 07:18:53 +03:00
d73f68e24b Merge pull request 'video_calls_fixes' (#1066) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1066
2026-10-05 11:56:40 +00:00
misha
61cded0081 version cnhanged 2026-10-05 13:13:25 +03:00
misha
df634fedd8 feat(video_calls): incommig call handling fix for ios and anroid 2026-10-05 13:12:55 +03:00
misha
603a158cf2 feat(diary): editing of previous and current finished days 2026-10-01 10:44:52 +03:00
a1bae68307 Merge pull request 'feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix' (#1064) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1064
2026-09-28 09:01:24 +00:00
misha
2dce28b196 feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix 2026-09-28 11:20:52 +03:00
ae4f9d2e9b Merge pull request 'fix(chat): ws manager' (#1060) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1060
Reviewed-by: misha <misha@noreply.localhost>
2026-09-25 07:31:28 +00:00
Anatoliy
a00aae36cf update version 2026-09-16 13:50:12 +03:00
Anatoliy
27e98bdb44 fix(chat): ws manager 2026-09-16 00:10:23 +03:00
Anatoliy
8ec6d5b799 refactor(chat): dedup 2026-09-15 23:20:00 +03:00
Anatoliy
077cfb3a67 refactor(chat): message dedup 2026-09-15 23:06:12 +03:00
c4f5cae36d Merge pull request 'fix: issue-942, issue-967, fixed images positioning for blog posts' (#1055) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1055
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-09-15 19:31:06 +00:00
misha
b040c6e020 fix: tests fix 2026-09-02 15:42:17 +03:00
misha
745e77f948 fix: analyer 2026-09-02 09:34:25 +03:00
misha
c6ea1d414d fix: issue-372, issue-227, issue-406 2026-09-02 09:32:14 +03:00
misha
10af279d87 fix: issue-942, issue-967, fixed images positioning for blog posts 2026-08-28 10:05:40 +03:00
d0879e556f Merge pull request 'version changed' (#1054) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1054
2026-08-27 07:21:39 +00:00
misha
8f933118a3 version changed 2026-08-27 08:58:09 +03:00
7dc7eab774 Merge pull request 'issue-1049, issue-1039, issue-1035, issue-1032, issue-995, issue-993' (#1053) from misha/eatvio_app:1-3-42_bug_fixes into main
Reviewed-on: eatvio/eatvio_app#1053
2026-08-26 08:07:39 +00:00
misha
16a13488d1 version changed 2026-08-26 10:27:48 +03:00
misha
de7bf8dc19 fix: issue-1049, issue-1039, issue-1035, issue-1032, issue-995, issue-993 2026-08-26 08:42:47 +03:00
eeb261df0a Merge pull request 'feat(image_validation)' (#1052) from misha/eatvio_app:issue-1037 into main
Reviewed-on: eatvio/eatvio_app#1052
2026-08-24 12:04:37 +00:00
misha
1b03a0d5e1 version changed 2026-08-24 11:55:39 +03:00
misha
0208ed753a feat(image_validation): image validation fix 2026-08-24 11:38:01 +03:00
184f0417a7 Merge pull request 'version changed' (#1051) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1051
2026-08-21 11:22:11 +00:00
misha
6265e865e9 version changed 2026-08-21 14:20:54 +03:00
1550b7ea0b Merge pull request 'fix: fixed domain matching for deeplinks' (#1050) from misha/eatvio_app:marketing_domains into main
Reviewed-on: eatvio/eatvio_app#1050
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-08-21 10:55:11 +00:00
misha
db10895ebb fix: removed path matching to handle links like https://{domain} 2026-08-21 10:25:09 +03:00
misha
a7361d3ab1 fix: fixed domain matching for deeplinks 2026-08-20 13:45:16 +03:00
1c54063a35 Merge pull request 'feat(chat,consultant_registration, portion_input)' (#1045) from chat_messages into main
Reviewed-on: eatvio/eatvio_app#1045
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-08-14 11:51:01 +00:00
efae7fc958 Merge pull request 'portion_input' (#1044) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1044
2026-08-14 10:48:00 +00:00
misha
90ac4e750d feat(portion_input): fixed portion input sizes 2026-08-14 13:46:00 +03:00
misha
6d7419c283 version changed 2026-08-14 12:59:04 +03:00
caf45a9050 Merge pull request 'Scoring validation' (#1043) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1043
2026-08-14 09:20:03 +00:00
misha
0a5e7d95c4 Merge branch 'chat_messages' of ssh://10.55.5.123:222/misha/eatvio_app into chat_messages 2026-08-14 12:16:46 +03:00
misha
85482173ad feat(consultant_scoring): added validation for last first and middle name for scoring 2026-08-14 12:16:03 +03:00
65f7992d40 Merge pull request 'feat(chat_messages): fixed data race for chat mesages state' (#1042) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1042
2026-08-14 08:07:56 +00:00
misha
52a01bee12 feat(chat_messages): fixed data race for chat mesages state 2026-08-14 11:05:03 +03:00
179fc0ad99 Merge pull request 'pasport_data_validation' (#1041) from misha/eatvio_app:pasport_data_validation into main
Reviewed-on: eatvio/eatvio_app#1041
2026-08-13 12:18:35 +00:00
misha
8494e61eec version changed 2026-08-13 12:29:18 +03:00
misha
5c2458ecad feat(consultant_refistration): added explicit validation for scoring data 2026-08-13 12:28:34 +03:00
b63bab8bf5 Merge pull request 'feat(post_creation_with_images)' (#1026) from post_creation_with_images into main
Reviewed-on: eatvio/eatvio_app#1026
2026-08-05 14:07:55 +00:00
7790014726 Merge pull request 'Version changed' (#1029) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1029
2026-08-05 13:33:20 +00:00
b679e90ba2 Merge pull request 'fix: analyzer fix' (#1027) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1027
2026-08-05 12:34:27 +00:00
2474c5bea3 Merge pull request 'post_creation_with_images' (#1025) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1025
2026-08-05 12:31:23 +00:00
7f278d0f59 Merge pull request 'fix: fixed script run time location' (#1024) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1024
2026-08-04 15:36:07 +00:00
misha
53426c858c fix: fixed script run time location 2026-08-04 18:35:32 +03:00
88f0cde88f Merge pull request 'fix: added pods updating' (#1023) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1023
2026-08-04 15:19:44 +00:00
misha
a5fcf62524 fix: added pods updating 2026-08-04 18:19:10 +03:00
a2e41a2ff5 Merge pull request 'fix: removed SPM' (#1022) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1022
2026-08-04 15:13:32 +00:00
misha
5b1beafbab fix: removed SPM 2026-08-04 18:12:55 +03:00
4eadecd6f2 Merge pull request 'fix: removed spm flag' (#1021) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1021
2026-08-04 14:52:33 +00:00
misha
6292db07a1 fix: removed spm flag 2026-08-04 17:51:58 +03:00
ab29b6b329 Merge pull request 'fix: ci/cd fix' (#1020) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1020
2026-08-04 14:48:54 +00:00
misha
af5d182a79 fix: ci/cd fix 2026-08-04 17:48:08 +03:00
191bb74aa4 Merge pull request 'Version changed' (#1018) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1018
2026-08-04 14:35:33 +00:00
133 changed files with 9550 additions and 3007 deletions

View File

@@ -59,12 +59,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

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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',

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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()));

View File

@@ -3,6 +3,7 @@ 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/notifications/notification_permission_requester.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:flutter/services.dart';
import 'package:device_info_plus/device_info_plus.dart';
@@ -16,9 +17,14 @@ 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;
@@ -29,16 +35,6 @@ class FirebaseApi {
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,
@@ -47,8 +43,20 @@ class FirebaseApi {
return token;
}
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');
}
}
Future<void> registerAnonymousPushToken() async {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
await sl<Dio>().post(
'/api/anonymous_token',
@@ -73,6 +81,7 @@ class FirebaseApi {
Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
await sl<Dio>().post(

View File

@@ -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;
}
}
}
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -25,6 +25,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;
@@ -38,6 +39,7 @@ class CustomPopup extends StatelessWidget {
const CustomPopup({
super.key,
required this.isAuthor,
this.isAuthorAdmin = false,
this.complainedContentType,
required this.path,
this.id,
@@ -49,6 +51,9 @@ class CustomPopup extends StatelessWidget {
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);
@@ -167,19 +172,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',
'Заблокировать автора',

View 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;
}
}

View File

@@ -198,6 +198,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
actions: [
CustomPopup(
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
isAuthorAdmin: blogPostState.blogPostModel?.author.isAdmin ?? false,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(widget.id)}',
id: widget.id,

View File

@@ -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 &&

View File

@@ -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) {
@@ -759,7 +763,6 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(
@@ -839,6 +842,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();
@@ -907,6 +915,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 +998,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 +1038,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
required double height,
required ImageProvider imageProvider,
required VoidCallback onRemove,
bool hasError = false,
}) {
return ReorderableDelayedDragStartListener(
index: index,
@@ -992,12 +1046,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),
),
),

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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,
),
),
),
),
);

View File

@@ -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',
};
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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(),
),

View File

@@ -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(),
),
);
},
),
),
);
}

View File

@@ -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' => 'Некорректный телефон СБП',

View File

@@ -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;
}
}
}

View File

@@ -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),
),
),
),
),

View File

@@ -53,7 +53,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
if (state.planCreated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
context.pop();
context.pop(true);
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,

View File

@@ -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),
],
],
);
},

View File

@@ -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,
),
),
);
}

View File

@@ -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,
);
}

View File

@@ -0,0 +1,38 @@
/// Finished and last-day-finished diaries stay editable on the calendar day
/// itself and on the next calendar day. Older finished days stay read-only.
const int diaryFinishedEditWindowInDays = 1;
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);
}

View File

@@ -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),

View File

@@ -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),
),
),
),

View File

@@ -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];

View File

@@ -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,
),
],
),
],

View File

@@ -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),
],
],
),

View File

@@ -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,
),
),
],
);

View File

@@ -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,
),
],
),
],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),
],
),
),
),
],
],
),
),

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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];

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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),
],
);

View File

@@ -2,6 +2,7 @@ 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/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';
@@ -92,7 +93,7 @@ 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)
? 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(),
],

View File

@@ -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,

View File

@@ -85,6 +85,7 @@ 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,

View File

@@ -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,

View File

@@ -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()),
),
],
),
);
}
}

View File

@@ -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);
},
),
],

View File

@@ -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(

View File

@@ -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',

View File

@@ -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,6 +194,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
actions: [
CustomPopup(
isAuthor: state.planModel?.author.authorIsMe ?? false,
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
path: 'plan/${widget.slug}',
complainedContentType: ComplainedContent.plan,
userSlug: state.planModel?.author.authorSlug ?? '',
@@ -182,20 +230,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,6 +374,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
actions: [
CustomPopup(
isAuthor: state.planModel?.author.authorIsMe ?? false,
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
path: 'plan/${widget.slug}',
complainedContentType: ComplainedContent.plan,
userSlug: state.planModel?.author.authorSlug ?? '',

View File

@@ -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),
),
),
),
],
);
}
}

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
@@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
@@ -382,19 +384,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
),
const SizedBox(height: 8),
SizedBox(
height: 781,
child: PageView.builder(
itemBuilder: (BuildContext context, int index) {
return PlanWidget(plan: state.plansList[index], index: index);
},
onPageChanged: (bullet) {
setState(() {
_currentBullet = bullet;
});
},
itemCount: state.plansList.length,
),
ExpandablePageView(
itemCount: state.plansList.length,
onPageChanged: (bullet) {
setState(() {
_currentBullet = bullet;
});
},
itemBuilder: (BuildContext context, int index) {
return PlanWidget(plan: state.plansList[index], index: index);
},
),
const SizedBox(height: 8),
_bullets(_currentBullet, state.plansList.length),
@@ -779,7 +778,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)],
),
child: SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40),
child: NetworkSvgPicture(
url: '${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}',
width: 40,
height: 40,
),
)
: Tooltip(
preferBelow: true,
@@ -1105,7 +1108,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
);
},
),
if (!state.isMyProfile) ...[
if (!state.isMyProfile && !state.profile.roles.contains('admin')) ...[
_buildDivider(),
_buildCompactItem(
icon: 'assets/icons/complain.svg',

View File

@@ -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);
}
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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),
),
),
),
);
}
}

View File

@@ -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 (_) {}
}
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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());
});

View File

@@ -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;
}
}

View File

@@ -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,

View 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();
}
}

View File

@@ -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) {

View File

@@ -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),
],
),
),
],
),
),
),
],
),
);
},
),
),
),

View File

@@ -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(

View File

@@ -55,6 +55,9 @@ class MessageLookup extends MessageLookupByLibrary {
"average_pressure": MessageLookupByLibrary.simpleMessage(
"Средние показатели давления:",
),
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
"Минимальный размер изображения — 500×500 px",
),
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
"calories": MessageLookupByLibrary.simpleMessage("Калории"),

View File

@@ -2569,6 +2569,16 @@ class S {
);
}
/// `Минимальный размер изображения — 500×500 px`
String get blog_post_image_min_size_error {
return Intl.message(
'Минимальный размер изображения — 500×500 px',
name: 'blog_post_image_min_size_error',
desc: '',
args: [],
);
}
/// `Информация о заказе`
String get infoAboutOrder {
return Intl.message(

View File

@@ -317,6 +317,7 @@
"could_not_publish_blog_post": "Не удалось опубликовать пост",
"could_not_update_blog_post": "Не удалось обновить пост",
"could_not_delete_blog_post": "Не удалось удалить пост",
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
"infoAboutOrder": "Информация о заказе",
"to_order": "Заказать",
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",

View File

@@ -270,5 +270,6 @@
"video_call_close": "Закрыть",
"video_call_notification_in_progress": "Звонок идёт",
"video_call_notification_return": "Вернуться к звонку",
"video_call_notification_end": "Завершить звонок"
"video_call_notification_end": "Завершить звонок",
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px"
}

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.38+127
version: 1.3.47+136
publish_to: 'none'
environment:
@@ -93,6 +93,10 @@ dependencies:
wakelock_plus: ^1.5.2
sbp: 0.1.0
# html 0.15.7 removed top-level query_selector.matches used by flutter_html 3.0.0.
dependency_overrides:
html: 0.15.6
dev_dependencies:
build_runner: ^2.10.4
freezed: ^3.2.0

View File

@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('NotificationPermissionRequester', () {
test('вызывает requestPermission один раз', () async {
final requester = NotificationPermissionRequester();
var calls = 0;
await requester.requestOnce(() async {
calls++;
});
expect(calls, 1);
});
test('параллельные вызовы ждут один и тот же запрос', () async {
final requester = NotificationPermissionRequester();
var calls = 0;
Future<void> request() {
return requester.requestOnce(() async {
calls++;
await Future<void>.delayed(const Duration(milliseconds: 50));
});
}
await Future.wait<void>([request(), request(), request()]);
expect(calls, 1);
});
test('после завершения можно запросить снова', () async {
final requester = NotificationPermissionRequester();
var calls = 0;
Future<void> request() {
return requester.requestOnce(() async {
calls++;
});
}
await request();
await request();
expect(calls, 2);
});
});
group('единая точка запроса разрешения на уведомления', () {
test('старт приложения и CallKit не показывают отдельные системные окна', () {
final initializer = File('lib/core/app_initializer.dart').readAsStringSync();
final callkit = File('lib/features/video_calls/services/video_call_callkit_coordinator.dart').readAsStringSync();
final firebaseApi = File('lib/core/firebase_api.dart').readAsStringSync();
expect(initializer.contains('requestPermission'), isFalse);
expect(callkit.contains('requestNotificationPermission'), isFalse);
expect(firebaseApi.contains('_requestNotificationPermission'), isTrue);
expect(firebaseApi.contains('_firebaseMessaging.requestPermission()'), isTrue);
});
});
}

View File

@@ -0,0 +1,36 @@
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('inlineSvgCss', () {
test(
'inlines class fills from a style block so flutter_svg can paint colors',
() {
const svg = '''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<style type="text/css">
.st0{fill:#FFD200;}
.st1{fill:#FFFFFF;}
</style>
<circle class="st0" cx="20" cy="20" r="18"/>
<path class="st1" d="M10 20h20"/>
</svg>
''';
final result = inlineSvgCss(svg);
expect(result, isNot(contains('<style')));
expect(result, contains('fill="#FFD200"'));
expect(result, contains('fill="#FFFFFF"'));
expect(result, isNot(contains('class="st0"')));
},
);
test('keeps SVG without style blocks unchanged', () {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#00AA00" cx="10" cy="10" r="5"/></svg>';
expect(inlineSvgCss(svg), svg);
});
});
}

View File

@@ -252,5 +252,36 @@ void main() {
expect(manager.client, isNull);
});
test('reconnected - эмитит сигнал после восстановления соединения', () async {
final wsService = MockWsService();
final manager = WsManager(wsService: wsService, errorReporter: const NoOpErrorReporter());
final received = <void>[];
final subscription = manager.reconnected.listen(received.add);
manager.handleConnectionEstablished();
await Future<void>.delayed(Duration.zero);
expect(received, hasLength(1));
await subscription.cancel();
});
test('reconnected - переживает clear (поток не закрывается при логауте)', () async {
final wsService = MockWsService();
final manager = WsManager(wsService: wsService, errorReporter: const NoOpErrorReporter());
final received = <void>[];
final subscription = manager.reconnected.listen(received.add);
await manager.clear();
manager.handleConnectionEstablished();
await Future<void>.delayed(Duration.zero);
expect(received, hasLength(1));
await subscription.cancel();
});
});
}

View File

@@ -120,6 +120,29 @@ void main() {
expect(find.text('Заблокировать автора'), findsOneWidget);
});
testWidgets('CustomPopup for non-author admin hides complain but keeps block', (tester) async {
await tester.pumpWidget(
wrap(
const CustomPopup(
isAuthor: false,
isAuthorAdmin: true,
complainedContentType: ComplainedContent.plan,
path: 'plans/slug',
slug: 'slug',
userSlug: 'admin-user',
),
),
);
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpAndSettle();
expect(find.text('Скопировать ссылку'), findsOneWidget);
expect(find.text('Поделиться'), findsOneWidget);
expect(find.text('Пожаловаться'), findsNothing);
expect(find.text('Заблокировать автора'), findsOneWidget);
});
testWidgets('CustomPopup for author recipe does not show edit action', (tester) async {
await tester.pumpWidget(
wrap(

View File

@@ -128,6 +128,15 @@ void main() {
);
});
testWidgets('does not add extra space above the title field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
final titleTop = tester.getTopLeft(find.text('Заголовок')).dy;
final scrollTop = tester.getTopLeft(find.byType(SingleChildScrollView)).dy;
expect(titleTop - scrollTop, 16);
});
testWidgets('pre-fills form fields when editing existing blog post', (
tester,
) async {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,9 @@ import 'chat_screen_loaded_smoke_test.mocks.dart';
void main() {
group('ChatScreen loaded smoke', () {
setUpAll(() async {
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
dotenv.loadFromString(
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
);
await initializeDateFormatting('ru', null);
await initializeDateFormatting('ru_RU', null);
await initializeDateFormatting('RU', null);
@@ -37,7 +39,11 @@ void main() {
setUp(() async {
await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
sl.registerLazySingleton<WsManager>(() => _FakeWsManager());
});
@@ -47,7 +53,9 @@ void main() {
</svg>
''';
final fakeBundle = _FakeAssetBundle({'assets/icons/error_icon.svg': fakeSvg});
final fakeBundle = _FakeAssetBundle({
'assets/icons/error_icon.svg': fakeSvg,
});
Widget wrap(Widget child, ChatsCubit chatsCubit) {
return DefaultAssetBundle(
@@ -70,8 +78,18 @@ void main() {
id: '1',
chatRoomSettings: ChatRoomSettingsModel(name: 'room', state: 'active'),
users: Users(
sender: ChatUserModel(id: 1, name: 'Me', slug: 'me', profilePhotoUrl: null),
receiver: ChatUserModel(id: 2, name: 'User Two', slug: 'user-two', profilePhotoUrl: null),
sender: ChatUserModel(
id: 1,
name: 'Me',
slug: 'me',
profilePhotoUrl: null,
),
receiver: ChatUserModel(
id: 2,
name: 'User Two',
slug: 'user-two',
profilePhotoUrl: null,
),
),
chosenPlanId: null,
chosenPlan: null,
@@ -147,12 +165,19 @@ void main() {
);
when(chatRoomCubit.state).thenReturn(state);
when(chatRoomCubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty());
when(
chatRoomCubit.stream,
).thenAnswer((_) => const Stream<ChatRoomState>.empty());
when(chatRoomCubit.close()).thenAnswer((_) async => Future<void>.value());
when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId')))
.thenAnswer((_) async => 'uuid');
when(
chatRoomCubit.initChat(
any,
sendInitialMessage: anyNamed('sendInitialMessage'),
orderId: anyNamed('orderId'),
),
).thenAnswer((_) async => 'uuid');
when(chatRoomCubit.setTimer()).thenReturn(null);
when(chatRoomCubit.refreshChat()).thenAnswer((_) async {});
sl.registerFactory<ChatRoomCubit>(() => chatRoomCubit);
@@ -160,11 +185,17 @@ void main() {
when(chatsCubit.stream).thenAnswer((_) => const Stream.empty());
when(chatsCubit.close()).thenAnswer((_) async => Future<void>.value());
await tester.pumpWidget(wrap(const ChatScreen(uuid: 'uuid', sendInitialMessage: false), chatsCubit));
await tester.pumpWidget(
wrap(
const ChatScreen(uuid: 'uuid', sendInitialMessage: false),
chatsCubit,
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(ChatScreen), findsOneWidget);
expect(find.byType(RefreshIndicator), findsOneWidget);
expect(find.text('User Two'), findsOneWidget);
expect(find.text('hello'), findsOneWidget);
expect(find.text('hi'), findsOneWidget);

View File

@@ -1,4 +1,3 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/message_action_bottom_sheet.dart';
@@ -8,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
class _FakeAssetBundle extends CachingAssetBundle {
_FakeAssetBundle(this.assets);
@@ -66,7 +66,9 @@ void main() {
testWidgets('AboutOrderButton renders and taps', (tester) async {
var tapped = false;
await tester.pumpWidget(wrap(AboutOrderButton(onTap: () => tapped = true)));
await tester.pumpWidget(
wrap(AboutOrderButton(onTap: () => tapped = true)),
);
await tester.pump();
expect(find.byType(AboutOrderButton), findsOneWidget);
await tester.tap(find.byType(AboutOrderButton));
@@ -75,24 +77,44 @@ void main() {
});
testWidgets('MessageActionsBottomSheet renders', (tester) async {
await tester.pumpWidget(wrap(MessageActionsBottomSheet(resend: () {}, delete: () {})));
await tester.pumpWidget(
wrap(MessageActionsBottomSheet(resend: () {}, delete: () {})),
);
await tester.pump();
expect(find.byType(MessageActionsBottomSheet), findsOneWidget);
expect(find.text(S.current.resend), findsOneWidget);
expect(find.text(S.current.delete), findsOneWidget);
});
testWidgets('NotificationBanner renders for multiple banner types', (tester) async {
testWidgets('NotificationBanner renders for multiple banner types', (
tester,
) async {
await tester.pumpWidget(
wrap(
const SingleChildScrollView(
child: Column(
children: [
NotificationBanner(bannerType: BannerType.date, hasPersonalDiet: false),
NotificationBanner(
bannerType: BannerType.date,
hasPersonalDiet: false,
),
NotificationBanner(bannerType: BannerType.card),
NotificationBanner(bannerType: BannerType.collectCharge, planName: 'Plan', planPrice: 100, chosenPlanId: 1),
NotificationBanner(bannerType: BannerType.finishPlan, planName: 'Plan', planPrice: 100, chosenPlanId: 1),
NotificationBanner(bannerType: BannerType.setPersonalDiet, hasPersonalDiet: true),
NotificationBanner(
bannerType: BannerType.collectCharge,
planName: 'Plan',
planPrice: 100,
chosenPlanId: 1,
),
NotificationBanner(
bannerType: BannerType.finishPlan,
planName: 'Plan',
planPrice: 100,
chosenPlanId: 1,
),
NotificationBanner(
bannerType: BannerType.setPersonalDiet,
hasPersonalDiet: true,
),
],
),
),
@@ -101,5 +123,54 @@ void main() {
await tester.pump();
expect(find.byType(NotificationBanner), findsWidgets);
});
testWidgets('collect charge banner opens card settings with chosenPlanId', (
tester,
) async {
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Scaffold(
body: NotificationBanner(
bannerType: BannerType.collectCharge,
planName: 'Plan',
planPrice: 100,
chosenPlanId: 17,
),
),
),
GoRoute(
path: '/card-settings-screen',
builder: (context, state) =>
Scaffold(body: Text('chosenPlanId:${state.extra}')),
),
],
);
await tester.pumpWidget(
DefaultAssetBundle(
bundle: bundle,
child: MaterialApp.router(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
routerConfig: router,
),
),
);
await tester.pump();
expect(find.text('Получить выплату'), findsOneWidget);
await tester.tap(find.text('Получить выплату'));
await tester.pumpAndSettle();
expect(find.text('chosenPlanId:17'), findsOneWidget);
});
});
}

View File

@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
import 'package:eatvio_app/features/complaining/presentation/cubit/complaining_cubit.dart';
import 'package:eatvio_app/features/complaining/repositories/complaining_repository.dart';
@@ -12,6 +15,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'complaining_bottom_sheet_smoke_test.mocks.dart';
@@ -59,7 +63,40 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(ComplainingBottomSheet), findsOneWidget);
});
testWidgets('replaces send button with Loader while request is in progress', (tester) async {
final completer = Completer<void>();
when(
repository.sendPlanComplain(
slug: anyNamed('slug'),
category: anyNamed('category'),
body: anyNamed('body'),
),
).thenAnswer((_) => completer.future);
await tester.pumpWidget(
wrap(
const ComplainingBottomSheet(
complainedContentType: ComplainedContent.plan,
slug: 'test-slug',
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Спам'));
await tester.pumpAndSettle();
expect(find.text('Отправить жалобу'), findsOneWidget);
await tester.tap(find.text('Отправить жалобу'));
await tester.pump();
expect(find.byType(Loader), findsOneWidget);
expect(find.text('Отправить жалобу'), findsNothing);
completer.complete();
await tester.pump();
});
});
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/features/complaining/models/complain_model.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
@@ -97,6 +99,7 @@ void main() {
await cubit.complain();
expect(cubit.state.wasSentSuccessfully, true);
expect(cubit.state.isLoading, false);
verify(() => mockRepository.sendDietComplain(
id: id,
category: any(named: 'category'),
@@ -104,6 +107,30 @@ void main() {
)).called(1);
});
test('complain - ставит isLoading на время запроса', () async {
const id = 1;
final completer = Completer<void>();
when(() => mockRepository.sendDietComplain(
id: id,
category: any(named: 'category'),
body: any(named: 'body'),
)).thenAnswer((_) => completer.future);
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.diet, id: id);
cubit.setCategory(ComplainType.spam);
final future = cubit.complain();
expect(cubit.state.isLoading, true);
expect(cubit.state.wasSentSuccessfully, isNull);
completer.complete();
await future;
expect(cubit.state.isLoading, false);
expect(cubit.state.wasSentSuccessfully, true);
});
test('complain - возвращает false при ошибке', () async {
const id = 1;
@@ -119,6 +146,7 @@ void main() {
await cubit.complain();
expect(cubit.state.wasSentSuccessfully, false);
expect(cubit.state.isLoading, false);
});
test('complain - отправляет жалобу на plan', () async {

View File

@@ -158,6 +158,10 @@ void main() {
expect(valid.hasValidPassportData, isTrue);
expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse);
expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse);
expect(
valid.copyWith(birthDate: '2020-02-31').hasValidPassportData,
isFalse,
);
});
});
}

View File

@@ -463,7 +463,30 @@ void main() {
expect(cubit.state.isScoringPolling, isFalse);
});
test('registerConsultant blocks submit without passport data', () async {
test(
'retryScoring writes fieldErrors when passport data is invalid',
() async {
cubit.emit(
cubit.state.copyWith(
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
loading: false,
),
);
await cubit.retryScoring();
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
verifyNever(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(named: 'update'),
),
);
},
);
test('registerConsultant writes fieldErrors without calling API', () async {
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
@@ -473,7 +496,10 @@ void main() {
await cubit.registerConsultant();
expect(cubit.state.error, 'Заполните паспортные данные');
expect(cubit.state.fieldErrors['last_name'], 'Укажите фамилию');
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
expect(cubit.state.fieldErrors['birth_date'], 'Укажите дату рождения');
expect(cubit.state.error, isNull);
verifyNever(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
@@ -482,6 +508,19 @@ void main() {
);
});
test('editing a field clears only that field error', () async {
await cubit.registerConsultant();
expect(cubit.state.fieldErrors.containsKey('last_name'), isTrue);
expect(cubit.state.fieldErrors.containsKey('inn'), isTrue);
cubit.updateConsultantProfileModel(
cubit.state.consultant.copyWith(lastName: 'Иванов'),
);
expect(cubit.state.fieldErrors.containsKey('last_name'), isFalse);
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
});
test('registerConsultant maps Laravel 422 into fieldErrors', () async {
when(
() => repository.registerConsultant(

View File

@@ -4,6 +4,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
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/presentation/pages/passport_data_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -60,5 +61,19 @@ void main() {
expect(find.text('Банк получатель (поиск)'), findsOneWidget);
expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget);
});
testWidgets('shows ValidationErrorWidget for invalid field', (
tester,
) async {
cubit.emit(
cubit.state.copyWith(fieldErrors: const {'inn': 'Некорректный ИНН'}),
);
await tester.pumpWidget(wrap(const PassportDataScreen()));
await tester.pump();
expect(find.byType(ValidationErrorWidget), findsOneWidget);
expect(find.text('Некорректный ИНН'), findsOneWidget);
});
});
}

View File

@@ -49,7 +49,26 @@ void main() {
);
expect(parsed.fieldErrors['last_name'], 'Укажите фамилию');
expect(parsed.fieldErrors['agreement_accepted'], 'Примите условия оферты');
expect(
parsed.fieldErrors['agreement_accepted'],
'Примите условия оферты',
);
});
test('maps name format errors', () {
final parsed = parseLaravelValidationErrors(
dio422({
'errors': {
'last_name': ['The last name format is invalid.'],
'first_name': ['The first name format is invalid.'],
'middle_name': ['The middle name format is invalid.'],
},
}),
);
expect(parsed.fieldErrors['last_name'], 'Некорректная фамилия');
expect(parsed.fieldErrors['first_name'], 'Некорректное имя');
expect(parsed.fieldErrors['middle_name'], 'Некорректное отчество');
});
test('falls back to generic message for non-422 DioException', () {

Some files were not shown because too many files have changed in this diff Show More