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>
This commit is contained in:
2026-08-14 11:51:01 +00:00
13 changed files with 677 additions and 287 deletions

View File

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

View File

@@ -54,16 +54,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (currentPage < lastPage && !isLoading) { if (currentPage < lastPage && !isLoading) {
isLoading = true; isLoading = true;
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading)); _emitLoaded((current) => current.copyWith(isLoading: isLoading));
try { try {
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1); final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
isLoading = false; isLoading = false;
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
isLoading: isLoading, 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}) { void _setCard({required List<CardModel> cards}) {
final currentState = state; if (cards.where((card) => card.isDefault).isNotEmpty) {
if (currentState is ChatRoomStateLoaded) { _emitLoaded((current) => current.copyWith(hasCard: true));
if (cards.where((card) => card.isDefault).isNotEmpty) {
emit(currentState.copyWith(hasCard: true));
}
} }
} }
@@ -117,6 +114,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
throw ArgumentError('initChat requires uuid or orderId'); throw ArgumentError('initChat requires uuid or orderId');
} }
currentPage = 1;
isLoading = false;
final chatRoom = await chatRepository.getChatRoom(resolvedUuid); final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
ChosenPlanModel? chosenPlan; ChosenPlanModel? chosenPlan;
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId; final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
@@ -135,6 +135,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
hasCard: false, hasCard: false,
), ),
); );
await _subscribeToPresenceChannel(chatId: chatRoom.id);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) { if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards(); await checkCards();
@@ -154,13 +155,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
Future<void> updateChosenPlanState() async { Future<void> updateChosenPlanState() async {
try { try {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is! ChatRoomStateLoaded) {
final chosenPlanId = currentState.chatRoom.chosenPlanId; return;
if (chosenPlanId != null) {
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
}
} }
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) { } catch (e, st) {
await reportFrom(e, st, this); 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 { Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
final client = wsManager.client; final client = wsManager.client;
if (client == null) return; if (client == null) return;
_activeChatUuid = uuid; _activeChatUuid = uuid;
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
messagesSubscription = null;
typingSubscription = null;
await wsManager.initPrivateDirectChatChannel(chatId); await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel(); wsManager.subscribeDirectChatChannel();
await _subscribeToPresenceChannel(chatId: chatId); messagesSubscription = wsManager.directChatStream?.listen(
messagesSubscription = wsManager.directChatStream?.listen((event) { _onDirectChatEvent,
AudioPlayer().play((AssetSource('audio/recive_message.wav'))); onError: (Object e, StackTrace st) {
final message = ChatMessageModel.fromJson(jsonDecode(event.data)); unawaited(reportFrom(e, st, this));
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());
});
typingSubscription = wsManager.clientTypingStream?.listen((event) { typingSubscription = wsManager.clientTypingStream?.listen((event) {
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading)); _emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading));
EasyDebounce.debounce( EasyDebounce.debounce(
'client-typing', 'client-typing',
const Duration(seconds: 3), 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 { Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state; final currentState = state;
if (currentState is! ChatRoomStateLoaded) return; if (currentState is! ChatRoomStateLoaded) return;
@@ -341,59 +398,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
try { try {
final messageUuid = uuid.v4(); final messageUuid = uuid.v4();
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null; 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 _emitLoaded(
emit( (current) => current.copyWith(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: [ chatMessages: [
ChatMessageModel( ChatMessageModel(
id: 1, id: -messageUuid.hashCode.abs(),
data: data ?? '', data: data ?? '',
mediaData: compressedFiles, mediaData: compressedFiles,
authorId: 1, authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isRead: true, isRead: true,
isCurrentUserAuthor: true, isCurrentUserAuthor: true,
roomId: '', roomId: current.chatRoom.id,
messageState: MessageState.loading, messageState: MessageState.loading,
uuid: messageUuid, uuid: messageUuid,
), ),
...(state as ChatRoomStateLoaded).chatMessages, ...current.chatMessages,
], ],
), ),
); );
try { try {
// Отправляем сообщение и получаем ответ от сервера с правильными данными
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
_replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage);
// Заменяем локальное сообщение данными с сервера (с правильными 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(),
),
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == messageUuid) { if (elem.uuid == messageUuid) {
return elem.copyWith(messageState: MessageState.error); 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 { try {
if (state is ChatRoomStateLoaded) { if (state is! ChatRoomStateLoaded) {
final ids = messagesIds.map((e) => e.id).toList(); return false;
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;
} }
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) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); 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 { Future<String?> getPaymentLink() async {
try { try {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
@@ -477,14 +542,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override @override
Future<void> close() async { Future<void> close() async {
EasyDebounce.cancel('client-typing');
EasyDebounce.cancel('searchChatRoom');
if (_presenceOnlineListener != null) { if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null; _presenceOnlineListener = null;
} }
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
_timer?.cancel(); _timer?.cancel();
messagesSubscription?.cancel(); await messagesSubscription?.cancel();
typingSubscription?.cancel(); await typingSubscription?.cancel();
super.close(); super.close();
} }
@@ -501,67 +568,46 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid); final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
if (message != null) { if (message != null) {
try { try {
AudioPlayer().play((AssetSource('audio/send_message.wav'))); try {
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
} catch (_) {}
// Определяем, есть ли файлы для повторной отправки
List<File>? filesToResend; List<File>? filesToResend;
if (message.mediaData != null && message.mediaData!.isNotEmpty) { if (message.mediaData != null && message.mediaData!.isNotEmpty) {
// Если mediaData содержит File объекты, используем их
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
final firstItem = message.mediaData!.first; final firstItem = message.mediaData!.first;
if (firstItem is File) { if (firstItem is File) {
filesToResend = message.mediaData!.cast<File>(); filesToResend = message.mediaData!.cast<File>();
} }
} }
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: [ chatMessages: [
ChatMessageModel( ChatMessageModel(
id: 1, id: -uuid.hashCode.abs(),
data: message.data, data: message.data,
mediaData: message.mediaData, mediaData: message.mediaData,
authorId: 1, authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isRead: true, isRead: true,
isCurrentUserAuthor: true, isCurrentUserAuthor: true,
roomId: '', roomId: current.chatRoom.id,
messageState: MessageState.loading, messageState: MessageState.loading,
uuid: uuid, uuid: uuid,
), ),
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid), ...current.chatMessages.where((item) => item.uuid != uuid),
], ],
), ),
); );
try { try {
// Отправляем сообщение и получаем ответ от сервера
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug); final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
_replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage);
// Заменяем локальное сообщение данными с сервера
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(),
),
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == uuid) { if (elem.uuid == uuid) {
return elem.copyWith(messageState: MessageState.error); return elem.copyWith(messageState: MessageState.error);
} }

View File

@@ -141,8 +141,8 @@ class ChatsCubit extends Cubit<ChatsState> {
Future<void> _handleNewMessageViaSocket(dynamic data) async { Future<void> _handleNewMessageViaSocket(dynamic data) async {
try { try {
final json = jsonDecode(data); final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json['message']); final message = ChatMessageModel.fromJson(json);
final currentChatRooms = [...state.chatRooms]; final currentChatRooms = [...state.chatRooms];

View File

@@ -56,8 +56,7 @@ class _ChatScreenState extends State<ChatScreen> {
final scrollController = ScrollController(); final scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController(); final TextEditingController textEditingController = TextEditingController();
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>(); final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
final List<({int id, int index})> unreadMessagesIds = []; final List<int> unreadMessageIds = [];
final List<({int id, int index})> unreadMessagesIdsCopy = [];
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates(); final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
late AppLifecycleListener _listener; late AppLifecycleListener _listener;
List<File> pickedFiles = []; List<File> pickedFiles = [];
@@ -116,6 +115,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void dispose() { void dispose() {
EasyDebounce.cancel('mark_as_read');
scrollController.dispose(); scrollController.dispose();
_listener.dispose(); _listener.dispose();
chatRoomCubit.close(); chatRoomCubit.close();
@@ -337,21 +337,15 @@ class _ChatScreenState extends State<ChatScreen> {
isCurrentUser: isCurrentUserAuthor, isCurrentUser: isCurrentUserAuthor,
), ),
VisibilityDetector( VisibilityDetector(
key: Key(message.id.toString()), key: ValueKey('msg-${message.uuid ?? message.id}'),
onVisibilityChanged: (VisibilityInfo info) { onVisibilityChanged: (VisibilityInfo info) {
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) { _queueMarkAsRead(
unreadMessagesIdsCopy.add((id: message.id, index: index)); context: context,
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { chatId: state.chatRoom.id,
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy); message: message,
if (!result) return; isCurrentUserAuthor: isCurrentUserAuthor,
if (!context.mounted) return; visibleFraction: info.visibleFraction,
context.read<ChatsCubit>().reduceUnreadMessagesCount( );
chatId: state.chatRoom.id,
count: unreadMessagesIdsCopy.length,
);
unreadMessagesIdsCopy.clear();
});
}
}, },
child: _buildMessageBubble( child: _buildMessageBubble(
context: context, context: context,
@@ -391,18 +385,15 @@ class _ChatScreenState extends State<ChatScreen> {
isCurrentUser: isCurrentUserAuthor, isCurrentUser: isCurrentUserAuthor,
), ),
VisibilityDetector( VisibilityDetector(
key: Key(message.id.toString()), key: ValueKey('msg-${message.uuid ?? message.id}'),
onVisibilityChanged: (VisibilityInfo info) { onVisibilityChanged: (VisibilityInfo info) {
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) { _queueMarkAsRead(
unreadMessagesIds.add((id: message.id, index: index)); context: context,
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { chatId: state.chatRoom.id,
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds); message: message,
if (!result) return; isCurrentUserAuthor: isCurrentUserAuthor,
if (!context.mounted) return; visibleFraction: info.visibleFraction,
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length); );
unreadMessagesIds.clear();
});
}
}, },
child: _buildMessageBubble( child: _buildMessageBubble(
context: context, 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({ Widget _buildMessageBubble({
required BuildContext context, required BuildContext context,
required ChatRoomStateLoaded state, required ChatRoomStateLoaded state,

View File

@@ -69,6 +69,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
filter: {'#': RegExp(r'[0-9]')}, filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
);
@override @override
void initState() { void initState() {
@@ -301,6 +304,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Фамилия', hintText: 'Фамилия',
controller: lastNameController, controller: lastNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('last_name'), hasError: errors.containsKey('last_name'),
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),
@@ -311,6 +316,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Имя', hintText: 'Имя',
controller: firstNameController, controller: firstNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('first_name'), hasError: errors.containsKey('first_name'),
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),
@@ -321,6 +328,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Отчество', hintText: 'Отчество',
controller: middleNameController, controller: middleNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('middle_name'), hasError: errors.containsKey('middle_name'),
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),

View File

@@ -18,7 +18,8 @@ class LaravelValidationErrors {
} }
const _genericValidationSummary = 'Проверьте введённые данные'; const _genericValidationSummary = 'Проверьте введённые данные';
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; const _genericNetworkSummary =
'Не удалось отправить данные. Попробуйте ещё раз';
/// Parses Laravel `errors` map and maps them to user-facing Russian texts. /// Parses Laravel `errors` map and maps them to user-facing Russian texts.
/// Raw backend messages are never shown in the UI. /// Raw backend messages are never shown in the UI.
@@ -85,8 +86,7 @@ String mapConsultantValidationMessage({
raw.contains('required') || raw.contains('required') ||
raw.contains('обязательн') || raw.contains('обязательн') ||
raw.contains('required_if'); raw.contains('required_if');
final isAccepted = final isAccepted = raw.contains('accepted') || field.contains('agreement');
raw.contains('accepted') || field.contains('agreement');
final isUrl = raw.contains('url') || field.contains('link'); final isUrl = raw.contains('url') || field.contains('link');
final isFormat = final isFormat =
raw.contains('format') || raw.contains('format') ||
@@ -154,6 +154,9 @@ String mapConsultantValidationMessage({
'passport_number' => 'Некорректный номер паспорта', 'passport_number' => 'Некорректный номер паспорта',
'passport_issue_date' => 'Некорректная дата выдачи', 'passport_issue_date' => 'Некорректная дата выдачи',
'passport_issuer_code' => 'Некорректный код подразделения', 'passport_issuer_code' => 'Некорректный код подразделения',
'last_name' => 'Некорректная фамилия',
'first_name' => 'Некорректное имя',
'middle_name' => 'Некорректное отчество',
'birth_date' => 'Некорректная дата рождения', 'birth_date' => 'Некорректная дата рождения',
'citizenship' => 'Некорректный код гражданства', 'citizenship' => 'Некорректный код гражданства',
'sbp_phone' => 'Некорректный телефон СБП', 'sbp_phone' => 'Некорректный телефон СБП',

View File

@@ -8,6 +8,9 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consulta
abstract final class PassportDataValidator { abstract final class PassportDataValidator {
static const futureDateMessage = 'Дата не может быть в будущем'; 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( static Map<String, String> validate(
ConsultantProfileModel consultant, { ConsultantProfileModel consultant, {
DateTime? now, DateTime? now,
@@ -15,17 +18,28 @@ abstract final class PassportDataValidator {
final today = now ?? DateTime.now(); final today = now ?? DateTime.now();
final errors = <String, String>{}; final errors = <String, String>{};
_requireText( _requirePattern(
errors, errors,
key: 'last_name', key: 'last_name',
value: consultant.lastName, value: consultant.lastName,
pattern: alfaPersonNamePattern,
emptyMessage: 'Укажите фамилию', emptyMessage: 'Укажите фамилию',
invalidMessage: 'Некорректная фамилия',
); );
_requireText( _requirePattern(
errors, errors,
key: 'first_name', key: 'first_name',
value: consultant.firstName, value: consultant.firstName,
pattern: alfaPersonNamePattern,
emptyMessage: 'Укажите имя', emptyMessage: 'Укажите имя',
invalidMessage: 'Некорректное имя',
);
_optionalPattern(
errors,
key: 'middle_name',
value: consultant.middleName,
pattern: alfaPersonNamePattern,
invalidMessage: 'Некорректное отчество',
); );
_requirePattern( _requirePattern(
errors, errors,
@@ -148,6 +162,22 @@ abstract final class PassportDataValidator {
} }
} }
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( static void _requireDate(
Map<String, String> errors, { Map<String, String> errors, {
required String key, required String key,

View File

@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox( Expanded(
width: MediaQuery.of(context).size.width * 0.8 - 32, flex: 2,
child: CustomTextFormField( child: SizedBox(
controller: productNameController, child: CustomTextFormField(
autofocus: true, controller: productNameController,
hintText: 'Введите название продукта/блюда', autofocus: true,
onChanged: (query) { hintText: 'Введите название продукта/блюда',
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { onChanged: (query) {
selectedGlossary = null; EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null; selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null;
setState(() {});
return;
}
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
setState(() {}); setState(() {});
return; });
} },
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query); ),
setState(() {});
});
},
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
SizedBox( Expanded(
width: MediaQuery.of(context).size.width * 0.2 - 4, flex: 1,
child: CustomTextFormField( child: SizedBox(
controller: quantityController, child: CustomTextFormField(
keyboardType: TextInputType.number, controller: quantityController,
hintText: selectedGlossary == null keyboardType: TextInputType.number,
? '100' hintText: selectedGlossary == null
: selectedGlossary!.grInPortion == 0 ? '100'
? '100' : selectedGlossary!.grInPortion == 0
: '1', ? '100'
suffixText: selectedGlossary == null : '1',
? 'гр' suffixText: selectedGlossary == null
: selectedGlossary?.grInPortion == 0 ? 'гр'
? 'гр' : selectedGlossary?.grInPortion == 0
: 'шт', ? 'гр'
: 'шт',
),
), ),
), ),
], ],
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
), ),
), ),
if (widget.portionIndex != null) const SizedBox(width: 8), if (widget.portionIndex != null) const SizedBox(width: 8),
InkWell( Expanded(
onTap: addPortion, child: InkWell(
borderRadius: BorderRadius.circular(20), onTap: addPortion,
child: Ink( borderRadius: BorderRadius.circular(20),
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92), child: Ink(
height: 52, width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), height: 52,
child: Center( decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Text( child: Center(
widget.portionIndex == null ? 'Добавить' : 'Сохранить', child: Text(
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), widget.portionIndex == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
), ),
), ),
), ),

View File

@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox( Expanded(
width: MediaQuery.of(context).size.width * 0.76 - 32, flex: 2,
child: CustomTextFormField( child: SizedBox(
controller: productNameController, child: CustomTextFormField(
autofocus: true, controller: productNameController,
hintText: 'Введите название продукта/блюда', autofocus: true,
onChanged: (query) { hintText: 'Введите название продукта/блюда',
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { onChanged: (query) {
selectedGlossary = null; EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
if (query.isEmpty) { selectedGlossary = null;
glossariesList = []; if (query.isEmpty) {
glossariesList = [];
setState(() {});
return;
}
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
setState(() {}); setState(() {});
return; });
} },
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query); ),
setState(() {});
});
},
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
SizedBox( Expanded(
width: MediaQuery.of(context).size.width * 0.24 - 4, flex: 1,
child: CustomTextFormField( child: SizedBox(
controller: quantityController, child: CustomTextFormField(
keyboardType: TextInputType.number, controller: quantityController,
hintText: selectedGlossary == null keyboardType: TextInputType.number,
? '100' hintText: selectedGlossary == null
: selectedGlossary!.grInPortion == 0 ? '100'
? '100' : selectedGlossary!.grInPortion == 0
: '1', ? '100'
suffixText: selectedGlossary == null : '1',
? 'гр' suffixText: selectedGlossary == null
: selectedGlossary?.grInPortion == 0 ? 'гр'
? 'гр' : selectedGlossary?.grInPortion == 0
: 'шт', ? 'гр'
: 'шт',
),
), ),
), ),
], ],
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
), ),
), ),
if (widget.portion != null) const SizedBox(width: 8), if (widget.portion != null) const SizedBox(width: 8),
InkWell( Expanded(
onTap: () { child: InkWell(
// Автоматически выбираем первый элемент, если текст точно совпадает onTap: () {
if (selectedGlossary == null && // Автоматически выбираем первый элемент, если текст точно совпадает
glossariesList.isNotEmpty && if (selectedGlossary == null &&
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { glossariesList.isNotEmpty &&
selectedGlossary = glossariesList[0]; productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
} selectedGlossary = glossariesList[0];
}
if (widget.portion == null) { if (widget.portion == null) {
widget.diaryCubit.addProductToMeal( widget.diaryCubit.addProductToMeal(
diaryId: widget.state.diary.id, diaryId: widget.state.diary.id,
mealId: widget.state.selectedMeal, mealId: widget.state.selectedMeal,
productName: productNameController.text, productName: productNameController.text,
portion: selectedGlossary == null portion: selectedGlossary == null
? 'gr' ? 'gr'
: selectedGlossary!.grInPortion == 0 : selectedGlossary!.grInPortion == 0
? 'gr' ? 'gr'
: 'pcs', : 'pcs',
quantity: int.parse(quantityController.text), quantity: int.parse(quantityController.text),
productNutritionalValueId: selectedGlossary?.id, productNutritionalValueId: selectedGlossary?.id,
); );
} else { } else {
widget.diaryCubit.updateProductInMeal( widget.diaryCubit.updateProductInMeal(
diaryId: widget.state.diary.id, diaryId: widget.state.diary.id,
mealId: widget.state.selectedMeal, mealId: widget.state.selectedMeal,
portionId: widget.portion!.id, portionId: widget.portion!.id,
productName: productNameController.text, productName: productNameController.text,
portion: selectedGlossary == null portion: selectedGlossary == null
? 'gr' ? 'gr'
: selectedGlossary!.grInPortion == 0 : selectedGlossary!.grInPortion == 0
? 'gr' ? 'gr'
: 'pcs', : 'pcs',
quantity: int.parse(quantityController.text), quantity: int.parse(quantityController.text),
productNutritionalValueId: selectedGlossary?.id, productNutritionalValueId: selectedGlossary?.id,
portionState: widget.portion!.portionState, portionState: widget.portion!.portionState,
); );
} }
Navigator.of(context).pop(); Navigator.of(context).pop();
if (selectedGlossary == null && _previousProductName != productNameController.text) { if (selectedGlossary == null && _previousProductName != productNameController.text) {
showModalBottomSheet( showModalBottomSheet(
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, useRootNavigator: true,
context: context, context: context,
builder: (context) => Padding( builder: (context) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit), 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

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.39+128 version: 1.3.40+129
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@@ -200,7 +200,7 @@ void main() {
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async { test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
const chatId = 'chat-id'; const chatId = 'chat-id';
final messagesIds = [(id: 1, index: 0)]; final messagesIds = [1];
// Сначала инициализируем чат // Сначала инициализируем чат
when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom); when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom);
@@ -1443,5 +1443,218 @@ void main() {
verifyNever(mockChatRepository.getChatMessages(any, any)); 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

@@ -49,7 +49,26 @@ void main() {
); );
expect(parsed.fieldErrors['last_name'], 'Укажите фамилию'); 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', () { test('falls back to generic message for non-422 DioException', () {

View File

@@ -25,6 +25,34 @@ void main() {
expect(PassportDataValidator.validate(valid), isEmpty); 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', () { test('requires empty text fields', () {
final errors = PassportDataValidator.validate( final errors = PassportDataValidator.validate(
valid.copyWith(lastName: '', firstName: null), valid.copyWith(lastName: '', firstName: null),