Compare commits

...

33 Commits

Author SHA1 Message Date
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
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
44 changed files with 1848 additions and 826 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

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

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

@@ -38,13 +38,16 @@ class WsManager {
StreamSubscription? _eventSubscription;
StreamSubscription? _connectionEstablishedSubscription;
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;
Future<void> initialize() async {
if (client == null) {
@@ -107,6 +110,8 @@ class WsManager {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
_unsubscribe();
_subscribe();
_privateDirectChatChannel?.subscribe();
_presenceDirectChatChannel?.subscribe();
}
});
_eventSubscription = client?.eventStream.listen((event) {
@@ -170,6 +175,7 @@ class WsManager {
}
Future<void> initPrivateDirectChatChannel(String chatId) async {
_privateDirectChatChannel?.unsubscribe();
final token = await TokenStorage.getTokenFromStorage();
_privateDirectChatChannel = client?.privateChannel(
'$directChatChannelName.$chatId',
@@ -180,6 +186,8 @@ class WsManager {
},
),
);
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
}
Future<void> initPresenceDirectChatChannel(String chatId) async {
@@ -299,6 +307,8 @@ class WsManager {
void unsubscribeDirectChatChannel() {
_privateDirectChatChannel?.unsubscribe();
_directChatStream = null;
_clientTypingStream = null;
}
void unsubscribePresenceDirectChatChannel() {
@@ -331,6 +341,8 @@ class WsManager {
_privateDirectChatChannel = null;
_privateMessagesChannel = null;
_privateNotificationChannel = null;
_directChatStream = null;
_clientTypingStream = null;
}
}

View File

@@ -54,16 +54,16 @@ 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);
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 +86,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));
}
}
@@ -117,6 +114,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
throw ArgumentError('initChat requires uuid or orderId');
}
currentPage = 1;
isLoading = false;
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
ChosenPlanModel? chosenPlan;
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
@@ -135,6 +135,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
hasCard: false,
),
);
await _subscribeToPresenceChannel(chatId: chatRoom.id);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
@@ -154,13 +155,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
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);
}
@@ -193,38 +196,92 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
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 {
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.
bool addIncomingMessage(ChatMessageModel message) {
final currentUserId = wsManager.userId;
if (currentUserId != null && message.authorId == currentUserId) {
return false;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
if (currentState.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
return false;
}
final added = _emitLoaded((current) {
if (current.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
return current;
}
return current.copyWith(
chatMessages: [message, ...current.chatMessages],
isLoading: isLoading,
);
});
if (!added) {
return false;
}
chatRepository.emitNewMessageToChats(message);
return true;
}
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
@@ -341,59 +398,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
try {
final messageUuid = uuid.v4();
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
AudioPlayer().play((AssetSource('audio/send_message.wav')));
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(),
),
);
_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 +447,21 @@ 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,6 +469,35 @@ 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;
@@ -477,14 +542,16 @@ 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();
super.close();
}
@@ -501,67 +568,46 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
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(),
),
);
_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

@@ -141,8 +141,8 @@ class ChatsCubit extends Cubit<ChatsState> {
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 currentChatRooms = [...state.chatRooms];

View File

@@ -56,8 +56,7 @@ 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 List<int> unreadMessageIds = [];
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
late AppLifecycleListener _listener;
List<File> pickedFiles = [];
@@ -116,6 +115,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
EasyDebounce.cancel('mark_as_read');
scrollController.dispose();
_listener.dispose();
chatRoomCubit.close();
@@ -337,21 +337,15 @@ class _ChatScreenState extends State<ChatScreen> {
isCurrentUser: isCurrentUserAuthor,
),
VisibilityDetector(
key: Key(message.id.toString()),
key: ValueKey('msg-${message.uuid ?? message.id}'),
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(
chatId: state.chatRoom.id,
count: unreadMessagesIdsCopy.length,
);
unreadMessagesIdsCopy.clear();
});
}
_queueMarkAsRead(
context: context,
chatId: state.chatRoom.id,
message: message,
isCurrentUserAuthor: isCurrentUserAuthor,
visibleFraction: info.visibleFraction,
);
},
child: _buildMessageBubble(
context: context,
@@ -391,18 +385,15 @@ class _ChatScreenState extends State<ChatScreen> {
isCurrentUser: isCurrentUserAuthor,
),
VisibilityDetector(
key: Key(message.id.toString()),
key: ValueKey('msg-${message.uuid ?? message.id}'),
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();
});
}
_queueMarkAsRead(
context: context,
chatId: state.chatRoom.id,
message: message,
isCurrentUserAuthor: isCurrentUserAuthor,
visibleFraction: info.visibleFraction,
);
},
child: _buildMessageBubble(
context: context,
@@ -793,6 +784,33 @@ 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,

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

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

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

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

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

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.38+127
version: 1.3.42+131
publish_to: 'none'
environment:

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

@@ -200,7 +200,7 @@ void main() {
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
const chatId = 'chat-id';
final messagesIds = [(id: 1, index: 0)];
final messagesIds = [1];
// Сначала инициализируем чат
when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom);
@@ -1443,5 +1443,218 @@ void main() {
verifyNever(mockChatRepository.getChatMessages(any, any));
});
test('addIncomingMessage - добавляет входящее сообщение в начало списка', () async {
const uuid = 'room-uuid';
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
when(mockWsManager.directChatStream).thenReturn(null);
when(mockWsManager.clientTypingStream).thenReturn(null);
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
await chatRoomCubit.initChat(uuid);
await Future.delayed(const Duration(milliseconds: 200));
final incoming = ChatMessageModel(
id: 99,
data: 'Incoming socket message',
mediaData: null,
authorId: 2,
updatedAt: DateTime.now(),
isRead: false,
isCurrentUserAuthor: false,
roomId: 'chat-id',
);
chatRoomCubit.addIncomingMessage(incoming);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(state.chatMessages.first.id, 99);
expect(state.chatMessages.length, 2);
});
test('addIncomingMessage - не добавляет дубликат по id', () async {
const uuid = 'room-uuid';
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
when(mockWsManager.directChatStream).thenReturn(null);
when(mockWsManager.clientTypingStream).thenReturn(null);
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
await chatRoomCubit.initChat(uuid);
await Future.delayed(const Duration(milliseconds: 200));
final incoming = ChatMessageModel(
id: 1,
data: 'Duplicate of existing',
mediaData: null,
authorId: 2,
updatedAt: DateTime.now(),
isRead: false,
isCurrentUserAuthor: false,
roomId: 'chat-id',
);
chatRoomCubit.addIncomingMessage(incoming);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(state.chatMessages.length, 1);
expect(state.chatMessages.first.data, 'Test message');
});
test('addIncomingMessage - игнорирует собственные сообщения', () async {
const uuid = 'room-uuid';
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.userId).thenReturn(1);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
when(mockWsManager.directChatStream).thenReturn(null);
when(mockWsManager.clientTypingStream).thenReturn(null);
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
await chatRoomCubit.initChat(uuid);
await Future.delayed(const Duration(milliseconds: 200));
chatRoomCubit.addIncomingMessage(
ChatMessageModel(
id: 77,
data: 'Own echo',
mediaData: null,
authorId: 1,
updatedAt: DateTime.now(),
isRead: true,
isCurrentUserAuthor: true,
roomId: 'chat-id',
),
);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(state.chatMessages.length, 1);
expect(state.chatMessages.any((message) => message.id == 77), isFalse);
});
test('updateChosenPlanState - не затирает сообщения, пришедшие во время запроса', () async {
const uuid = 'room-uuid';
const planId = 123;
final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: planId);
const testPlan = PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'https://example.com/image.jpg',
duration: 7,
state: 'active',
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
details: 'Test details',
packetsCount: 3,
author: Author(authorSlug: 'author', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
isFavorite: false,
minPriceOfOptions: 1000,
maxPriceOfOptions: 2000,
wasLikedByMe: false,
);
final chosenPlan = ChosenPlanModel(
id: planId,
optionType: 'options1',
startDate: DateTime.now(),
chosenPlanState: ChosenPlanStateEnum.stateStarted,
chatId: 'chat-1',
consultantId: 1,
plan: testPlan,
);
final updatedPlan = chosenPlan.copyWith(chosenPlanState: ChosenPlanStateEnum.statePaid);
final planCompleter = Completer<ChosenPlanModel>();
var getChosenPlanCalls = 0;
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) {
getChosenPlanCalls++;
if (getChosenPlanCalls == 1) {
return Future.value(chosenPlan);
}
return planCompleter.future;
});
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
when(mockWsManager.directChatStream).thenReturn(null);
when(mockWsManager.clientTypingStream).thenReturn(null);
await chatRoomCubit.initChat(uuid);
await Future.delayed(const Duration(milliseconds: 200));
final updateFuture = chatRoomCubit.updateChosenPlanState();
chatRoomCubit.addIncomingMessage(
ChatMessageModel(
id: 55,
data: 'Arrived during plan poll',
mediaData: null,
authorId: 2,
updatedAt: DateTime.now(),
isRead: false,
isCurrentUserAuthor: false,
roomId: 'chat-id',
),
);
planCompleter.complete(updatedPlan);
await updateFuture;
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(state.chatMessages.any((message) => message.id == 55), isTrue);
expect(state.chatRoom.chosenPlan?.chosenPlanState, ChosenPlanStateEnum.statePaid);
});
test('markMessagesAsRead - не затирает сообщения, пришедшие во время запроса', () async {
const uuid = 'room-uuid';
const chatId = 'chat-id';
final markCompleter = Completer<void>();
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
when(mockWsManager.directChatStream).thenReturn(null);
when(mockWsManager.clientTypingStream).thenReturn(null);
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
when(mockChatRepository.markMessagesAsRead(chatId, [1])).thenAnswer((_) => markCompleter.future);
await chatRoomCubit.initChat(uuid);
await Future.delayed(const Duration(milliseconds: 200));
final markFuture = chatRoomCubit.markMessagesAsRead(chatId, [1]);
chatRoomCubit.addIncomingMessage(
ChatMessageModel(
id: 88,
data: 'Arrived during mark as read',
mediaData: null,
authorId: 2,
updatedAt: DateTime.now(),
isRead: false,
isCurrentUserAuthor: false,
roomId: chatId,
),
);
markCompleter.complete();
await markFuture;
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(state.chatMessages.any((message) => message.id == 88), isTrue);
expect(state.chatMessages.firstWhere((message) => message.id == 1).isRead, isTrue);
});
});
}

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', () {

View File

@@ -0,0 +1,120 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const valid = ConsultantProfileModel(
lastName: 'Иванов',
firstName: 'Иван',
inn: '123456789012',
snils: '123-456-789 00',
passportSeries: '1234',
passportNumber: '567890',
passportIssueDate: '2020-01-15',
passportIssuer: 'ОВД',
passportIssuerCode: '772-053',
birthDate: '1990-05-20',
birthPlace: 'Москва',
registrationAddress: 'Адрес 1',
residenceAddress: 'Адрес 2',
citizenship: 'RU',
);
group('PassportDataValidator', () {
test('returns empty map for a valid individual person profile', () {
expect(PassportDataValidator.validate(valid), isEmpty);
});
test('rejects names that do not match Alfa Bank pattern', () {
final errors = PassportDataValidator.validate(
valid.copyWith(
lastName: 'Regress14LastName',
firstName: 'Regress14Name',
middleName: 'Regress14middleName',
),
);
expect(errors['last_name'], 'Некорректная фамилия');
expect(errors['first_name'], 'Некорректное имя');
expect(errors['middle_name'], 'Некорректное отчество');
});
test('allows hyphenated names and empty middle name', () {
final errors = PassportDataValidator.validate(
valid.copyWith(
lastName: 'Салтыков-Щедрин',
firstName: 'Mary Jane',
middleName: '',
),
);
expect(errors.containsKey('last_name'), isFalse);
expect(errors.containsKey('first_name'), isFalse);
expect(errors.containsKey('middle_name'), isFalse);
});
test('requires empty text fields', () {
final errors = PassportDataValidator.validate(
valid.copyWith(lastName: '', firstName: null),
);
expect(errors['last_name'], 'Укажите фамилию');
expect(errors['first_name'], 'Укажите имя');
});
test('rejects short INN and malformed SNILS', () {
final errors = PassportDataValidator.validate(
valid.copyWith(inn: '123', snils: '12345678900'),
);
expect(errors['inn'], 'Некорректный ИНН');
expect(errors['snils'], 'Некорректный СНИЛС');
});
test('rejects empty INN and SNILS', () {
final errors = PassportDataValidator.validate(
valid.copyWith(inn: '', snils: ''),
);
expect(errors['inn'], 'Укажите ИНН');
expect(errors['snils'], 'Укажите СНИЛС');
});
test('rejects non-calendar dates', () {
final errors = PassportDataValidator.validate(
valid.copyWith(
birthDate: '2020-02-31',
passportIssueDate: '1999-13-32',
),
);
expect(errors['birth_date'], 'Некорректная дата рождения');
expect(errors['passport_issue_date'], 'Некорректная дата выдачи');
});
test('rejects dates in the future', () {
final errors = PassportDataValidator.validate(
valid.copyWith(
birthDate: '2030-01-01',
passportIssueDate: '2030-01-01',
),
now: DateTime(2026, 8, 13),
);
expect(errors['birth_date'], PassportDataValidator.futureDateMessage);
expect(
errors['passport_issue_date'],
PassportDataValidator.futureDateMessage,
);
});
test('accepts today as a valid date', () {
final errors = PassportDataValidator.validate(
valid.copyWith(passportIssueDate: '2026-08-13'),
now: DateTime(2026, 8, 13, 18),
);
expect(errors.containsKey('passport_issue_date'), isFalse);
});
});
}

View File

@@ -4,9 +4,12 @@ import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/calendar_widget.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:table_calendar/table_calendar.dart';
class FakeDiaryCubit extends Cubit<DiaryState> implements DiaryCubit {
FakeDiaryCubit(super.initialState);
@@ -16,9 +19,20 @@ class FakeDiaryCubit extends Cubit<DiaryState> implements DiaryCubit {
void main() {
group('CalendarWidget smoke', () {
Widget wrap(Widget child, DiaryCubit cubit) {
setUpAll(() async {
await initializeDateFormatting('ru', null);
await initializeDateFormatting('ru_RU', null);
});
Widget wrap(Widget child, DiaryCubit cubit, {double textScale = 1}) {
return MaterialApp(
theme: ThemeHelper().themeData(),
builder: (context, appChild) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(textScale)),
child: appChild!,
);
},
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
@@ -37,9 +51,26 @@ void main() {
final cubit = FakeDiaryCubit(const DiaryState.loading());
await tester.pumpWidget(wrap(const CalendarWidget(), cubit));
await tester.pump();
// Just make sure it builds.
expect(find.byType(CalendarWidget), findsOneWidget);
await cubit.close();
});
testWidgets('scales weekday row when system font is large', (tester) async {
const textScale = 2.0;
final cubit = FakeDiaryCubit(const DiaryState.loading());
await tester.pumpWidget(wrap(const CalendarWidget(), cubit, textScale: textScale));
await tester.pump();
final calendar = tester.widget<TableCalendar<dynamic>>(find.byType(TableCalendar<dynamic>));
expect(calendar.daysOfWeekHeight, greaterThan(16 * textScale * 0.9));
expect(calendar.rowHeight, 52 * textScale);
expect(find.text('пн'), findsWidgets);
final weekday = tester.renderObject<RenderParagraph>(find.text('пн').first);
expect(weekday.size.height, lessThanOrEqualTo(calendar.daysOfWeekHeight + 0.5));
expect(tester.takeException(), isNull);
await cubit.close();
});
});
}

View File

@@ -156,6 +156,44 @@ void main() {
expect(dietsCubit!.state.errorMessage, isNull);
});
test('loadDiets - сбрасывает currentPage при повторной загрузке', () async {
dietsCubit = DietsCubit(
feedRepository: mockFeedRepository,
dietsRepository: mockDietRepository,
);
const dietsPagination = DietsPagination(
data: [],
links: Links(
first: 'https://example.com?page=1',
last: 'https://example.com?page=1',
prev: null,
next: null,
),
meta: Meta(
currentPage: 1,
from: null,
lastPage: 1,
links: [],
perPage: 10,
to: null,
total: 0,
),
);
when(mockFeedRepository.getDiets(
query: anyNamed('query'),
types: anyNamed('types'),
duration: anyNamed('duration'),
sorting: anyNamed('sorting'),
)).thenAnswer((_) async => dietsPagination);
dietsCubit!.currentPage = 4;
await dietsCubit!.loadDiets();
expect(dietsCubit!.currentPage, 1);
});
test('loadDiets - обработка ошибки', () async {
dietsCubit = DietsCubit(
feedRepository: mockFeedRepository,

View File

@@ -131,6 +131,31 @@ void main() {
expect(plansCubit!.state.errorMessage, isNull);
});
test('loadPlans - сбрасывает currentPage при повторной загрузке', () async {
plansCubit = PlansCubit(feedRepository: mockFeedRepository, planRepository: mockPlanRepository);
const plansPagination = PlanPaginationModel(
data: [],
links: Links(first: 'https://example.com?page=1', last: 'https://example.com?page=1', prev: null, next: null),
meta: Meta(currentPage: 1, from: 1, lastPage: 1, links: [], perPage: 10, to: 1, total: 0),
);
when(
mockFeedRepository.getPlans(
query: anyNamed('query'),
types: anyNamed('types'),
duration: anyNamed('duration'),
minPrice: anyNamed('minPrice'),
maxPrice: anyNamed('maxPrice'),
),
).thenAnswer((_) async => plansPagination);
plansCubit!.currentPage = 4;
await plansCubit!.loadPlans();
expect(plansCubit!.currentPage, 1);
});
test('loadPlans - обработка ошибки', () async {
plansCubit = PlansCubit(feedRepository: mockFeedRepository, planRepository: mockPlanRepository);

View File

@@ -110,6 +110,18 @@ void main() {
expect(recipesCubit!.state.errorMessage, isNull);
});
test('loadRecipesList - сбрасывает currentPage при повторной загрузке', () async {
recipesCubit = RecipesCubit(feedRepository: mockFeedRepository, recipeRepository: mockRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository);
when(mockFeedRepository.getRecipes(tags: anyNamed('tags'), query: anyNamed('query'))).thenAnswer((_) async => []);
when(mockRecipeRepository.getRecipesTags()).thenAnswer((_) async => []);
recipesCubit!.currentPage = 4;
await recipesCubit!.loadRecipesList();
expect(recipesCubit!.currentPage, 1);
});
test('updateTags - добавляет и удаляет теги', () {
recipesCubit = RecipesCubit(feedRepository: mockFeedRepository, recipeRepository: mockRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository);

View File

@@ -136,4 +136,19 @@ void main() {
expect(find.byType(FeedScreen), findsOneWidget);
});
});
group('shouldReloadFeedAfterCreate', () {
test('перезагружает ленту после публикации диеты или плана', () {
expect(shouldReloadFeedAfterCreate(true), isTrue);
});
test('перезагружает ленту после публикации рецепта', () {
expect(shouldReloadFeedAfterCreate('active'), isTrue);
});
test('не перезагружает ленту при отмене или черновике рецепта', () {
expect(shouldReloadFeedAfterCreate(null), isFalse);
expect(shouldReloadFeedAfterCreate('drafted'), isFalse);
});
});
}

View File

@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.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/profile/models/profile_model.dart';
import 'package:eatvio_app/features/settings/entities/settings_entity.dart';
import 'package:eatvio_app/features/settings/models/profile_settings_model.dart';
@@ -130,5 +133,52 @@ void main() {
expect(find.byType(GeneralInfoScreen), findsOneWidget);
});
testWidgets('лоадер при сохранении появляется вместо кнопки, а не поверх экрана', (tester) async {
final cubit = MockSettingsCubit();
const state = SettingsState.loaded(
profileSettings: mockProfileSettings,
profileDetails: mockProfileDetails,
profile: mockProfile,
hasActiveConsultation: false,
);
final saveCompleter = Completer<void>();
when(cubit.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateProfileDetails(profileDetailsModel: anyNamed('profileDetailsModel'))).thenAnswer((_) => saveCompleter.future);
when(cubit.updateProfileSettings(profileSettingsModel: anyNamed('profileSettingsModel'))).thenAnswer((_) async {});
final settingsEntity = SettingsEntity(profileSettingsModel: mockProfileSettings, profileDetailsModel: mockProfileDetails, settingsCubit: cubit);
await tester.pumpWidget(
BlocProvider<SettingsCubit>.value(
value: cubit,
child: wrap(GeneralInfoScreen(settingsEntity: settingsEntity)),
),
);
await tester.pump();
expect(find.text('Сохранить'), findsOneWidget);
expect(find.byType(Loader), findsNothing);
await tester.tap(find.text('Сохранить'));
await tester.pump();
expect(find.text('Сохранить'), findsNothing);
expect(find.byType(Loader), findsOneWidget);
expect(find.text('Введите имя'), findsOneWidget);
final loaderSize = tester.getSize(find.byType(Loader));
expect(loaderSize.height, lessThan(60));
saveCompleter.complete();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('Сохранить'), findsOneWidget);
expect(find.byType(Loader), findsNothing);
});
});
}