diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 3631e0ac..9ab8fe19 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -38,13 +38,16 @@ class WsManager { StreamSubscription? _eventSubscription; StreamSubscription? _connectionEstablishedSubscription; + Stream? _directChatStream; + Stream? _clientTypingStream; + Stream? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); Stream? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated'); - Stream? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); + Stream? get directChatStream => _directChatStream; - Stream? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing'); + Stream? get clientTypingStream => _clientTypingStream; Future 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 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 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; } } diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index 452adb18..27b81f50 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -54,16 +54,16 @@ class ChatRoomCubit extends Cubit { 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 { } void _setCard({required List 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 { 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 { hasCard: false, ), ); + await _subscribeToPresenceChannel(chatId: chatRoom.id); if (chosenPlan?.consultantId == chatRoom.users.sender.id) { await checkCards(); @@ -154,13 +155,15 @@ class ChatRoomCubit extends Cubit { Future 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 { } } + 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 _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 messagesList = [message, ...currentState.chatMessages]; - - chatRepository.emitNewMessageToChats(message); - emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading)); - EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl().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))) { + unawaited(_playIncomingSound()); + } + } catch (e, st) { + unawaited(reportFrom(e, st, this)); + } + } + + Future _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 _subscribeToPresenceChannel({required String chatId}) async { final currentState = state; if (currentState is! ChatRoomStateLoaded) return; @@ -341,59 +398,38 @@ class ChatRoomCubit extends Cubit { try { final messageUuid = uuid.v4(); final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List.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 { } } - Future markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async { + Future markMessagesAsRead(String chatId, List 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 { } } + 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 getPaymentLink() async { try { final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; @@ -477,14 +542,16 @@ class ChatRoomCubit extends Cubit { @override Future 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 { 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? filesToResend; if (message.mediaData != null && message.mediaData!.isNotEmpty) { - // Если mediaData содержит File объекты, используем их - // Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна final firstItem = message.mediaData!.first; if (firstItem is File) { filesToResend = message.mediaData!.cast(); } } - 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); } diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index 52e1631e..10f91095 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -141,8 +141,8 @@ class ChatsCubit extends Cubit { Future _handleNewMessageViaSocket(dynamic data) async { try { - final json = jsonDecode(data); - final message = ChatMessageModel.fromJson(json['message']); + final json = jsonDecode(data) as Map; + final message = ChatMessageModel.fromJson(json); final currentChatRooms = [...state.chatRooms]; diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 432bebdb..4ef7ba4e 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -56,8 +56,7 @@ class _ChatScreenState extends State { final scrollController = ScrollController(); final TextEditingController textEditingController = TextEditingController(); final ChatRoomCubit chatRoomCubit = sl(); - final List<({int id, int index})> unreadMessagesIds = []; - final List<({int id, int index})> unreadMessagesIdsCopy = []; + final List unreadMessageIds = []; final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates(); late AppLifecycleListener _listener; List pickedFiles = []; @@ -116,6 +115,7 @@ class _ChatScreenState extends State { @override void dispose() { + EasyDebounce.cancel('mark_as_read'); scrollController.dispose(); _listener.dispose(); chatRoomCubit.close(); @@ -337,21 +337,15 @@ class _ChatScreenState extends State { 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().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy); - if (!result) return; - if (!context.mounted) return; - context.read().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 { 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().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds); - if (!result) return; - if (!context.mounted) return; - context.read().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 { ); } + 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.from(unreadMessageIds); + if (ids.isEmpty) { + return; + } + unreadMessageIds.clear(); + final result = await context.read().markMessagesAsRead(chatId, ids); + if (!result || !context.mounted) { + return; + } + context.read().reduceUnreadMessagesCount(chatId: chatId, count: ids.length); + }); + } + Widget _buildMessageBubble({ required BuildContext context, required ChatRoomStateLoaded state, diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index a14a9399..4cde5a95 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -69,6 +69,9 @@ class _PassportDataScreenState extends State { filter: {'#': RegExp(r'[0-9]')}, type: MaskAutoCompletionType.lazy, ); + final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'), + ); @override void initState() { @@ -301,6 +304,8 @@ class _PassportDataScreenState extends State { child: CustomTextFormField( hintText: 'Фамилия', controller: lastNameController, + maxLength: 50, + inputFormatters: [_alfaPersonNameFormatter], hasError: errors.containsKey('last_name'), onChanged: (_) => _syncToCubit(), ), @@ -311,6 +316,8 @@ class _PassportDataScreenState extends State { child: CustomTextFormField( hintText: 'Имя', controller: firstNameController, + maxLength: 50, + inputFormatters: [_alfaPersonNameFormatter], hasError: errors.containsKey('first_name'), onChanged: (_) => _syncToCubit(), ), @@ -321,6 +328,8 @@ class _PassportDataScreenState extends State { child: CustomTextFormField( hintText: 'Отчество', controller: middleNameController, + maxLength: 50, + inputFormatters: [_alfaPersonNameFormatter], hasError: errors.containsKey('middle_name'), onChanged: (_) => _syncToCubit(), ), diff --git a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart index d4b0d947..97be0dc1 100644 --- a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart +++ b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart @@ -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' => 'Некорректный телефон СБП', diff --git a/lib/features/consultant/become_consultant/utils/passport_data_validator.dart b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart index 73c0f1a6..6b71299f 100644 --- a/lib/features/consultant/become_consultant/utils/passport_data_validator.dart +++ b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart @@ -8,6 +8,9 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consulta 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 validate( ConsultantProfileModel consultant, { DateTime? now, @@ -15,17 +18,28 @@ abstract final class PassportDataValidator { final today = now ?? DateTime.now(); final errors = {}; - _requireText( + _requirePattern( errors, key: 'last_name', value: consultant.lastName, + pattern: alfaPersonNamePattern, emptyMessage: 'Укажите фамилию', + invalidMessage: 'Некорректная фамилия', ); - _requireText( + _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, @@ -148,6 +162,22 @@ abstract final class PassportDataValidator { } } + static void _optionalPattern( + Map 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 errors, { required String key, diff --git a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart index a8eb36d5..bfe5d823 100644 --- a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart +++ b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart @@ -59,43 +59,47 @@ class _AddMealWidgetState extends State { 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 { ), ), 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), + ), ), ), ), diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index a0895bf9..611652cf 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -70,42 +70,46 @@ class _AddProductModalState extends State { 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 { ), ), 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), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 3be79617..a0d96551 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.39+128 +version: 1.3.40+129 publish_to: 'none' environment: diff --git a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart index d1f14bba..38793443 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -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(); + 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(); + + 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); + }); }); } diff --git a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart index 4470e4e3..88a0d328 100644 --- a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart +++ b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart @@ -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', () { diff --git a/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart index 3e32f758..fd568e36 100644 --- a/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart +++ b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart @@ -25,6 +25,34 @@ void main() { 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),