diff --git a/ios/Podfile b/ios/Podfile index 833e2940..217b4ecc 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -44,6 +44,8 @@ post_install do |installer| '$(inherited)', ## dart: PermissionGroup.camera 'PERMISSION_CAMERA=1', + ## dart: PermissionGroup.microphone + 'PERMISSION_MICROPHONE=1', ## dart: PermissionGroup.photos 'PERMISSION_PHOTOS=1', 'PERMISSION_NOTIFICATIONS=1', diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 15dfb671..766a5ed4 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -4,6 +4,7 @@ import Flutter import PushKit import UIKit import UserNotifications +import WebRTC import YandexLoginSDK import flutter_callkit_incoming import flutter_foreground_task @@ -15,7 +16,11 @@ import flutter_foreground_task private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" + private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session" private let appUrlScheme = "eatvio" + /// CallKit is only the ringing UI. After accept the in-app call continues, + /// so the audio session must be handed back to WebRTC when CallKit deactivates. + private var resumeAudioAfterCallKitEnds = false override func application( _ application: UIApplication, @@ -57,6 +62,27 @@ import flutter_foreground_task result(FlutterMethodNotImplemented) } } + + let callAudioChannel = FlutterMethodChannel( + name: callAudioSessionChannelName, + binaryMessenger: controller.binaryMessenger + ) + callAudioChannel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result(nil) + return + } + switch call.method { + case "activate": + self.activateInAppCallAudioSession() + result(nil) + case "deactivate": + self.deactivateInAppCallAudioSession() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } } return launched @@ -482,6 +508,8 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + resumeAudioAfterCallKitEnds = true + prepareCallAudioCategory() storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", @@ -500,6 +528,7 @@ import flutter_foreground_task } func onDecline(_ call: Call, _ action: CXEndCallAction) { + resumeAudioAfterCallKitEnds = false action.fulfill() } @@ -509,9 +538,71 @@ import flutter_foreground_task func onTimeOut(_ call: Call) {} - func didActivateAudioSession(_ audioSession: AVAudioSession) {} + func didActivateAudioSession(_ audioSession: AVAudioSession) { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.audioSessionDidActivate(audioSession) + rtcSession.isAudioEnabled = true + } - func didDeactivateAudioSession(_ audioSession: AVAudioSession) {} + func didDeactivateAudioSession(_ audioSession: AVAudioSession) { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.audioSessionDidDeactivate(audioSession) + guard resumeAudioAfterCallKitEnds else { return } + DispatchQueue.main.async { [weak self] in + guard let self, self.resumeAudioAfterCallKitEnds else { return } + self.activateInAppCallAudioSession() + } + } - func providerDidReset() {} + func providerDidReset() { + resumeAudioAfterCallKitEnds = false + } + + private func prepareCallAudioCategory() { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + let configuration = callAudioConfiguration() + do { + try rtcSession.setConfiguration(configuration) + } catch { + NSLog("[EatvioVoIP] prepare call audio category failed: \(error)") + } + } + + private func activateInAppCallAudioSession() { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setConfiguration(callAudioConfiguration(), active: true) + rtcSession.isAudioEnabled = true + } catch { + NSLog("[EatvioVoIP] activate call audio session failed: \(error)") + } + } + + private func deactivateInAppCallAudioSession() { + resumeAudioAfterCallKitEnds = false + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.isAudioEnabled = false + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setActive(false) + } catch { + NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)") + } + } + + private func callAudioConfiguration() -> RTCAudioSessionConfiguration { + let configuration = RTCAudioSessionConfiguration.webRTC() + configuration.category = AVAudioSession.Category.playAndRecord.rawValue + configuration.categoryOptions = [ + .allowBluetooth, + .defaultToSpeaker, + ] + configuration.mode = AVAudioSession.Mode.voiceChat.rawValue + return configuration + } } 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 377f0fe9..27ffb620 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 @@ -40,11 +40,18 @@ class ChatRoomCubit extends Cubit { void Function(bool isOnline)? _presenceOnlineListener; var uuid = const Uuid(); - ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); + ChatRoomCubit({ + required this.chatRepository, + required this.wsManager, + required this.cardsRepository, + }) : super(const ChatRoomState.loading()); void setTimer() { _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState()); + _timer = Timer.periodic( + const Duration(seconds: 5), + (_) => updateChosenPlanState(), + ); } void cancelTimer() { @@ -58,13 +65,19 @@ class ChatRoomCubit extends Cubit { _emitLoaded((current) => current.copyWith(isLoading: isLoading)); try { - final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1); + final chatMessages = await chatRepository.getChatMessages( + uuid, + currentPage + 1, + ); isLoading = false; _emitLoaded( (current) => current.copyWith( isLoading: isLoading, - chatMessages: [...current.chatMessages, ...chatMessages.messagesList], + chatMessages: [ + ...current.chatMessages, + ...chatMessages.messagesList, + ], ), ); @@ -98,7 +111,11 @@ class ChatRoomCubit extends Cubit { /// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`. /// /// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve). - Future initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async { + Future initChat( + String? uuid, { + String? orderId, + bool sendInitialMessage = false, + }) async { try { emit(const ChatRoomState.loading()); @@ -106,7 +123,9 @@ class ChatRoomCubit extends Cubit { int? resolvedChosenPlanId; if (orderId != null && orderId.isNotEmpty) { - final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId); + final resolved = await chatRepository.resolveOrderIdToConversation( + orderId: orderId, + ); resolvedUuid = resolved.conversationUuid; resolvedChosenPlanId = resolved.chosenPlanId; } @@ -117,6 +136,7 @@ class ChatRoomCubit extends Cubit { currentPage = 1; isLoading = false; + _activeChatUuid = resolvedUuid; final chatRoom = await chatRepository.getChatRoom(resolvedUuid); ChosenPlanModel? chosenPlan; @@ -124,7 +144,10 @@ class ChatRoomCubit extends Cubit { if (planId != null) { chosenPlan = await chatRepository.getChosenPlan(id: planId); } - final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage); + final chatMessages = await chatRepository.getChatMessages( + resolvedUuid, + currentPage, + ); lastPage = chatMessages.meta.lastPage; await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id); _listenToReconnect(); @@ -177,7 +200,9 @@ class ChatRoomCubit extends Cubit { final page = await chatRepository.getChatMessages(uuid, 1); lastPage = page.meta.lastPage; _emitLoaded((current) { - final byId = {for (final message in current.chatMessages) message.id: message}; + final byId = { + for (final message in current.chatMessages) message.id: message, + }; for (final message in page.messagesList) { byId.putIfAbsent(message.id, () => message); } @@ -190,6 +215,50 @@ class ChatRoomCubit extends Cubit { } } + /// Перезагружает сообщения, комнату и состояние плана без экрана загрузки. + Future refreshChat() async { + final uuid = _activeChatUuid; + if (uuid == null || uuid.isEmpty) { + return; + } + final currentState = state; + if (currentState is! ChatRoomStateLoaded) { + return; + } + + try { + currentPage = 1; + isLoading = false; + + final chatRoom = await chatRepository.getChatRoom(uuid); + ChosenPlanModel? chosenPlan; + final planId = + chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId; + if (planId != null) { + chosenPlan = await chatRepository.getChosenPlan(id: planId); + } + final chatMessages = await chatRepository.getChatMessages( + uuid, + currentPage, + ); + lastPage = chatMessages.meta.lastPage; + + _emitLoaded( + (latest) => latest.copyWith( + chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan), + chatMessages: chatMessages.messagesList, + isLoading: false, + ), + ); + + if (chosenPlan?.consultantId == chatRoom.users.sender.id) { + await checkCards(); + } + } catch (e, st) { + await reportFrom(e, st, this); + } + } + Future updateChosenPlanState() async { try { final currentState = state; @@ -213,9 +282,16 @@ class ChatRoomCubit extends Cubit { final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId; if (planId == null) return false; final stringDate = date.format('yyyy-MM-dd'); - await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate}); + await chatRepository.choseStartPlanDate( + id: planId, + date: {'start_date': stringDate}, + ); final chosenPlan = await chatRepository.getChosenPlan(id: planId); - emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan)); + emit( + (state as ChatRoomStateLoaded).copyWith.chatRoom( + chosenPlan: chosenPlan, + ), + ); return true; } catch (e, st) { await reportFrom(e, st, this); @@ -224,7 +300,10 @@ class ChatRoomCubit extends Cubit { } } - Future cancelChosenPlan({required String chatId, required int planId}) async { + Future cancelChosenPlan({ + required String chatId, + required int planId, + }) async { try { await chatRepository.cancelChosenPlan(id: planId); emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null)); @@ -234,7 +313,9 @@ class ChatRoomCubit extends Cubit { } } - bool _emitLoaded(ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update) { + bool _emitLoaded( + ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update, + ) { if (isClosed) { return false; } @@ -246,7 +327,10 @@ class ChatRoomCubit extends Cubit { return true; } - Future _subscribeToChatChannel({required String uuid, required String chatId}) async { + Future _subscribeToChatChannel({ + required String uuid, + required String chatId, + }) async { // Активный чат запоминаем до проверки клиента: он нужен и для догрузки // пропущенного после реконнекта (S5), даже если сокет появится позже. _activeChatUuid = uuid; @@ -266,18 +350,26 @@ class ChatRoomCubit extends Cubit { ); typingSubscription = wsManager.clientTypingStream?.listen((event) { - _emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading)); + _emitLoaded( + (current) => current.copyWith(isTyping: true, isLoading: isLoading), + ); EasyDebounce.debounce( 'client-typing', const Duration(seconds: 3), - () => _emitLoaded((current) => current.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))) { + if (addIncomingMessage( + ChatMessageModel.fromJson( + jsonDecode(event.data) as Map, + ), + )) { unawaited(_playIncomingSound()); } } catch (e, st) { @@ -305,7 +397,6 @@ class ChatRoomCubit extends Cubit { if (currentState is! ChatRoomStateLoaded) { return false; } - final currentUserId = wsManager.userId; final isOwn = currentUserId != null && message.authorId == currentUserId; @@ -321,7 +412,11 @@ class ChatRoomCubit extends Cubit { } final added = _emitLoaded((current) { - if (current.chatMessages.any((existing) => message.id != 0 && existing.id == message.id)) { + if (current.chatMessages.any( + (existing) => + (message.id != 0 && existing.id == message.id) || + (message.uuid != null && existing.uuid == message.uuid), + )) { return current; } return current.copyWith( @@ -352,12 +447,15 @@ class ChatRoomCubit extends Cubit { await wsManager.initPresenceDirectChatChannel(chatId); _presenceOnlineListener = (isOnline) { - unawaited(_handleCounterpartOnlineChanged( - counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id - ? _ChatCounterpartField.receiver - : _ChatCounterpartField.sender, - isOnline: isOnline, - )); + unawaited( + _handleCounterpartOnlineChanged( + counterpartField: + counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender, + isOnline: isOnline, + ), + ); }; wsManager.addPresenceDirectChatListener( counterpartUserId: counterpart.id, @@ -376,7 +474,8 @@ class ChatRoomCubit extends Cubit { ? currentState.chatRoom.users.receiver : currentState.chatRoom.users.sender; - if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) { + if (counterpart.isOnline == isOnline && + (isOnline || counterpart.lastSeenText.isNotEmpty)) { return; } @@ -453,10 +552,16 @@ class ChatRoomCubit extends Cubit { ); } - Future sendMessage({String? data, List? files, required String userSlug}) async { + Future sendMessage({ + String? data, + List? files, + required String userSlug, + }) async { try { final messageUuid = uuid.v4(); - final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List.from(files)) : null; + final compressedFiles = files != null + ? await ImageCompressUtility.compressFilesList(List.from(files)) + : null; try { unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav'))); } catch (_) {} @@ -482,8 +587,15 @@ class ChatRoomCubit extends Cubit { ); try { - final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); - _replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage); + final serverMessage = await chatRepository.sendMessage( + data: data, + files: compressedFiles, + slug: userSlug, + ); + _replaceOptimisticMessage( + messageUuid: messageUuid, + serverMessage: serverMessage, + ); } catch (e, st) { await reportFrom(e, st, this); _emitLoaded( @@ -516,7 +628,11 @@ class ChatRoomCubit extends Cubit { _emitLoaded( (current) => current.copyWith( chatMessages: current.chatMessages - .map((message) => idSet.contains(message.id) ? message.copyWith(isRead: true) : message) + .map( + (message) => idSet.contains(message.id) + ? message.copyWith(isRead: true) + : message, + ) .toList(), ), ); @@ -538,17 +654,26 @@ class ChatRoomCubit extends Cubit { 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); + 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: [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, + roomId: elem.roomId?.isNotEmpty == true + ? elem.roomId + : serverMessage.roomId, ); } return elem; @@ -561,7 +686,10 @@ class ChatRoomCubit extends Cubit { try { final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; if (planId == null) return null; - final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank'); + final paymentModel = await chatRepository.getPaymentLink( + id: planId, + paymentProvider: 'alfa-bank', + ); return paymentModel.paymentUrl; } catch (e, st) { await reportFrom(e, st, this); @@ -618,18 +746,31 @@ class ChatRoomCubit extends Cubit { void deleteMessage(String uuid) { final currentState = state; if (currentState is ChatRoomStateLoaded) { - emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList())); + emit( + currentState.copyWith( + chatMessages: currentState.chatMessages + .where((message) => message.uuid != uuid) + .toList(), + ), + ); } } - Future resendMessage({required String uuid, required String userSlug}) async { + Future resendMessage({ + required String uuid, + required String userSlug, + }) async { final currentState = state; if (currentState is ChatRoomStateLoaded) { - final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid); + final message = currentState.chatMessages.firstWhereOrNull( + (message) => message.uuid == uuid, + ); if (message != null) { try { try { - unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav'))); + unawaited( + AudioPlayer().play(AssetSource('audio/send_message.wav')), + ); } catch (_) {} List? filesToResend; @@ -661,8 +802,15 @@ class ChatRoomCubit extends Cubit { ); try { - final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug); - _replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage); + final serverMessage = await chatRepository.sendMessage( + data: message.data, + files: filesToResend, + slug: userSlug, + ); + _replaceOptimisticMessage( + messageUuid: uuid, + serverMessage: serverMessage, + ); } catch (e, st) { await reportFrom(e, st, this); _emitLoaded( diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 4ef7ba4e..0a58510a 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -46,7 +46,12 @@ class ChatScreen extends StatefulWidget { final String? orderId; final bool? sendInitialMessage; - const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage}); + const ChatScreen({ + this.uuid, + this.orderId, + super.key, + required this.sendInitialMessage, + }); @override State createState() => _ChatScreenState(); @@ -57,7 +62,8 @@ class _ChatScreenState extends State { final TextEditingController textEditingController = TextEditingController(); final ChatRoomCubit chatRoomCubit = sl(); final List unreadMessageIds = []; - final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates(); + final showableChatBadgeStates = + ChosenPlanStateEnum.getShowableChatBadgeStates(); late AppLifecycleListener _listener; List pickedFiles = []; @@ -108,7 +114,8 @@ class _ChatScreenState extends State { void _onScroll() { final uuid = conversationUuid; if (uuid == null) return; - if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) { + if (scrollController.offset >= scrollController.position.maxScrollExtent && + !scrollController.position.outOfRange) { chatRoomCubit.loadNextPage(uuid); } } @@ -168,9 +175,13 @@ class _ChatScreenState extends State { } Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) { - final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl().userId); + final chatCounterpart = resolveChatCounterpart( + users: state.chatRoom.users, + currentUserId: sl().userId, + ); final chosenPlan = state.chatRoom.chosenPlan; - final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id; + final amIConsultant = + chosenPlan?.consultantId == state.chatRoom.users.sender.id; final showUserDetailsButton = [ ChosenPlanStateEnum.stateStartDateHasBeenSet, ChosenPlanStateEnum.statePendingPayment, @@ -186,22 +197,37 @@ class _ChatScreenState extends State { } final showAboutOrderButton = - state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator; + state.chatRoom.chosenPlan != null && + state.chatRoom.chosenPlan!.chosenPlanState != + ChosenPlanStateEnum.stateCanceledByTheInitiator; BannerType? bannerType; - if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) { + if (chosenPlan == null || + chosenPlan.chosenPlanState == + ChosenPlanStateEnum.stateCanceledByTheInitiator) { bannerType = null; - } else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { + } else if (amIConsultant && + !state.hasCard && + chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { bannerType = BannerType.card; - } else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { + } else if (amIConsultant && + chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { bannerType = BannerType.date; } else if (!amIConsultant && - (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) { + (chosenPlan.chosenPlanState == + ChosenPlanStateEnum.stateStartDateHasBeenSet || + chosenPlan.chosenPlanState == + ChosenPlanStateEnum.statePendingPayment)) { bannerType = BannerType.payment; - } else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) { + } else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && + amIConsultant) { bannerType = BannerType.collectCharge; - } else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) { + } else if (chosenPlan.chosenPlanState == + ChosenPlanStateEnum.stateLastDayFinished && + !amIConsultant) { bannerType = BannerType.finishPlan; - } else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) { + } else if (chosenOption?.hasPersonalDiet == true && + amIConsultant && + chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) { bannerType = BannerType.setPersonalDiet; } @@ -223,25 +249,40 @@ class _ChatScreenState extends State { title: GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug); + GoRouter.of(context).pushNamed( + 'profileScreen', + extra: state.chatRoom.users.receiver.slug, + ); }, child: Row( children: [ if (state.chatRoom.users.receiver.profilePhotoUrl != null) CircleAvatar( radius: 20, - foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + foregroundImage: NetworkImage( + state.chatRoom.users.receiver.profilePhotoUrl!, + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', + ), ), if (state.chatRoom.users.receiver.profilePhotoUrl == null) Container( width: 40, height: 40, alignment: Alignment.center, - decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle), + decoration: const BoxDecoration( + color: Color(0xFFEBF4FF), + shape: BoxShape.circle, + ), child: Text( - state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0], - style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20), + state.chatRoom.users.receiver.name.split(' ').first[0] + + state.chatRoom.users.receiver.name.split(' ')[1][0], + style: const TextStyle( + color: Color(0xFF7F9CF5), + fontWeight: FontWeight.w400, + fontSize: 20, + ), ), ), const SizedBox(width: 16), @@ -253,10 +294,17 @@ class _ChatScreenState extends State { state.chatRoom.users.receiver.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black), + style: CustomTextStyles.titleSmallBold.copyWith( + color: PrimaryColors().black, + ), ), AnimatedCrossFade( - firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary), + firstChild: Text( + 'Печатает...', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CustomTextStyles.bodySmallMulishPrimary, + ), firstCurve: Curves.easeIn, secondCurve: Curves.easeOut, secondChild: ChatUserOnlineStatusLine( @@ -265,7 +313,9 @@ class _ChatScreenState extends State { style: CustomTextStyles.bodySmallMulishPrimary, showOnlineDot: false, ), - crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, + crossFadeState: state.isTyping + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, duration: const Duration(milliseconds: 150), ), ], @@ -277,7 +327,9 @@ class _ChatScreenState extends State { actions: [ Padding( padding: const EdgeInsetsDirectional.only(end: 8), - child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)), + child: _ChatHeaderVideoCallButton( + onPressed: () => _openVideoCall(context, state, chatCounterpart), + ), ), ], ), @@ -291,44 +343,133 @@ class _ChatScreenState extends State { child: Container( color: appTheme.whiteA700, width: double.maxFinite, - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - controller: scrollController, - reverse: true, - child: Column( - children: [ - const SizedBox(height: 16), - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - reverse: true, - shrinkWrap: true, - itemBuilder: (context, index) { - if (index == state.chatMessages.length) { - return const Padding(padding: EdgeInsets.all(8), child: Loader()); - } + child: RefreshIndicator( + onRefresh: chatRoomCubit.refreshChat, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + controller: scrollController, + reverse: true, + child: Column( + children: [ + const SizedBox(height: 16), + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + reverse: true, + shrinkWrap: true, + itemBuilder: (context, index) { + if (index == state.chatMessages.length) { + return const Padding( + padding: EdgeInsets.all(8), + child: Loader(), + ); + } - if ((index + 1 != state.chatMessages.length && - state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) || - index + 1 == state.chatMessages.length) { - final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; + if ((index + 1 != state.chatMessages.length && + state + .chatMessages[index] + .updatedAt + .day != + state + .chatMessages[index + 1] + .updatedAt + .day) || + index + 1 == state.chatMessages.length) { + final isCurrentUserAuthor = + state.chatRoom.users.receiver.id != + state.chatMessages[index].authorId; + final message = state.chatMessages[index]; + final isPlanOrderMessage = + message.type == ChatMessageType.planOrder; + + PacketOptions? messageOption; + if (isPlanOrderMessage && + chosenPlan != null) { + if (chosenPlan.optionType == 'options1') { + messageOption = + chosenPlan.plan.packet1Options; + } else if (chosenPlan.optionType == + 'options2') { + messageOption = + chosenPlan.plan.packet2Options; + } else if (chosenPlan.optionType == + 'options3') { + messageOption = + chosenPlan.plan.packet3Options; + } + } + + return Column( + children: [ + _messageDate(message.updatedAt), + if (isPlanOrderMessage && + chosenPlan != null && + messageOption != null) + PlanOrderMessageWidget( + planName: chosenPlan.plan.name, + planSlug: chosenPlan.plan.slug, + price: messageOption.price, + sendTime: message.updatedAt, + isCurrentUser: isCurrentUserAuthor, + ), + VisibilityDetector( + key: ValueKey( + 'msg-${message.uuid ?? message.id}', + ), + onVisibilityChanged: + (VisibilityInfo info) { + _queueMarkAsRead( + context: context, + chatId: state.chatRoom.id, + message: message, + isCurrentUserAuthor: + isCurrentUserAuthor, + visibleFraction: + info.visibleFraction, + ); + }, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: + isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, + ), + ), + ], + ); + } + final isCurrentUserAuthor = + state.chatRoom.users.receiver.id != + state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == ChatMessageType.planOrder; + final isPlanOrderMessage = + message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { if (chosenPlan.optionType == 'options1') { - messageOption = chosenPlan.plan.packet1Options; - } else if (chosenPlan.optionType == 'options2') { - messageOption = chosenPlan.plan.packet2Options; - } else if (chosenPlan.optionType == 'options3') { - messageOption = chosenPlan.plan.packet3Options; + messageOption = + chosenPlan.plan.packet1Options; + } else if (chosenPlan.optionType == + 'options2') { + messageOption = + chosenPlan.plan.packet2Options; + } else if (chosenPlan.optionType == + 'options3') { + messageOption = + chosenPlan.plan.packet3Options; } } return Column( children: [ - _messageDate(message.updatedAt), - if (isPlanOrderMessage && chosenPlan != null && messageOption != null) + if (isPlanOrderMessage && + chosenPlan != null && + messageOption != null) PlanOrderMessageWidget( planName: chosenPlan.plan.name, planSlug: chosenPlan.plan.slug, @@ -337,117 +478,94 @@ class _ChatScreenState extends State { isCurrentUser: isCurrentUserAuthor, ), VisibilityDetector( - key: ValueKey('msg-${message.uuid ?? message.id}'), - onVisibilityChanged: (VisibilityInfo info) { - _queueMarkAsRead( - context: context, - chatId: state.chatRoom.id, - message: message, - isCurrentUserAuthor: isCurrentUserAuthor, - visibleFraction: info.visibleFraction, - ); - }, + key: ValueKey( + 'msg-${message.uuid ?? message.id}', + ), + onVisibilityChanged: + (VisibilityInfo info) { + _queueMarkAsRead( + context: context, + chatId: state.chatRoom.id, + message: message, + isCurrentUserAuthor: + isCurrentUserAuthor, + visibleFraction: + info.visibleFraction, + ); + }, child: _buildMessageBubble( context: context, state: state, chatCounterpart: chatCounterpart, - isCurrentUserAuthor: isCurrentUserAuthor, + isCurrentUserAuthor: + isCurrentUserAuthor, message: message, amIConsultant: amIConsultant, ), ), ], ); - } - final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; - final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == ChatMessageType.planOrder; - - PacketOptions? messageOption; - if (isPlanOrderMessage && chosenPlan != null) { - if (chosenPlan.optionType == 'options1') { - messageOption = chosenPlan.plan.packet1Options; - } else if (chosenPlan.optionType == 'options2') { - messageOption = chosenPlan.plan.packet2Options; - } else if (chosenPlan.optionType == 'options3') { - messageOption = chosenPlan.plan.packet3Options; - } - } - - return Column( - children: [ - if (isPlanOrderMessage && chosenPlan != null && messageOption != null) - PlanOrderMessageWidget( - planName: chosenPlan.plan.name, - planSlug: chosenPlan.plan.slug, - price: messageOption.price, - sendTime: message.updatedAt, - isCurrentUser: isCurrentUserAuthor, + }, + itemCount: + state.chatMessages.length + + (state.isLoading ? 1 : 0), + ), + const SizedBox(height: 16), + if (showUserDetailsButton) ...[ + // OrderCard( + // plan: chosenPlan.plan, + // isCustomerView: !amIConsultant, + // optionType: chosenPlan.optionType, + // onCancel: () async { + // await context.read().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id); + // }, + // ), + if (amIConsultant) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + child: CustomOutlinedButton( + text: S.current.user_info, + height: 52, + buttonTextStyle: TextStyles.textMSemibold + .copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll( + BorderSide.none, + ), + backgroundColor: WidgetStatePropertyAll( + appTheme.blueA400, + ), ), - VisibilityDetector( - key: ValueKey('msg-${message.uuid ?? message.id}'), - onVisibilityChanged: (VisibilityInfo info) { - _queueMarkAsRead( - context: context, - chatId: state.chatRoom.id, - message: message, - isCurrentUserAuthor: isCurrentUserAuthor, - visibleFraction: info.visibleFraction, + onPressed: () async { + GoRouter.of(context).push( + UserDetailsScreen.path, + extra: + state.chatRoom.users.receiver.slug, ); }, - child: _buildMessageBubble( - context: context, - state: state, - chatCounterpart: chatCounterpart, - isCurrentUserAuthor: isCurrentUserAuthor, - message: message, - amIConsultant: amIConsultant, - ), ), - ], - ); - }, - itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0), - ), - const SizedBox(height: 16), - if (showUserDetailsButton) ...[ - // OrderCard( - // plan: chosenPlan.plan, - // isCustomerView: !amIConsultant, - // optionType: chosenPlan.optionType, - // onCancel: () async { - // await context.read().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id); - // }, - // ), - if (amIConsultant) + ), + ], + if (bannerType != null) Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: CustomOutlinedButton( - text: S.current.user_info, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), - onPressed: () async { - GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug); - }, + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + child: NotificationBanner( + bannerType: bannerType, + chosenPlanId: chosenPlan?.id, + planName: chosenPlan?.plan.name, + planPrice: chosenOption?.price.toDouble(), + hasPersonalDiet: + chosenOption?.hasPersonalDiet ?? false, ), ), ], - if (bannerType != null) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: NotificationBanner( - bannerType: bannerType, - chosenPlanId: chosenPlan?.id, - planName: chosenPlan?.plan.name, - planPrice: chosenOption?.price.toDouble(), - hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false, - ), - ), - ], + ), ), ), ), @@ -472,19 +590,40 @@ class _ChatScreenState extends State { _isImageFile(pickedFiles[i].path) ? GestureDetector( onTap: () { - final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList(); - final imageIndex = _imageIndexAt(pickedFiles, i); + final imageFiles = pickedFiles + .where( + (f) => _isImageFile(f.path), + ) + .toList(); + final imageIndex = _imageIndexAt( + pickedFiles, + i, + ); context.pushTransparentRoute( - PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex), + PhotoViewScreen( + imageUrls: imageFiles + .map((e) => e.path) + .toList(), + isLocal: true, + index: imageIndex, + ), ); }, child: Hero( tag: pickedFiles[i].path, transitionOnUserGestures: true, child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)), + borderRadius: + BorderRadius.circular(8), + child: SizedBox( + height: 104, + width: 104, + child: Image.file( + pickedFiles[i], + fit: BoxFit.cover, + ), + ), ), ), ) @@ -501,8 +640,15 @@ class _ChatScreenState extends State { }, child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: BoxDecoration( + color: appTheme.gray50, + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), @@ -519,14 +665,20 @@ class _ChatScreenState extends State { children: [ const SizedBox(width: 8), InkWell( - onTap: () => showImagePicker(state.chatRoom.users.receiver.slug), + onTap: () => showImagePicker( + state.chatRoom.users.receiver.slug, + ), customBorder: const CircleBorder(), child: Ink( height: 40, width: 40, child: Transform.rotate( angle: pi / 4, - child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)), + child: const Icon( + Icons.attach_file_rounded, + size: 30, + color: Color(0xFF858E99), + ), ), ), ), @@ -538,24 +690,46 @@ class _ChatScreenState extends State { maxLines: 3, minLines: 1, keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.black, + height: 1, + ), decoration: InputDecoration( isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), hintText: 'Сообщение', - hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)), + hintStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1, + color: Color(0xFFAEAEB2), + ), filled: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(100), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(100), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(100), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), ), ), @@ -570,12 +744,18 @@ class _ChatScreenState extends State { final blockedState = blockedUsersCubit.state; if (blockedState is BlockedUsersStateLoaded) { - final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug); + final isBlocked = blockedState.profilesList.any( + (profile) => + profile.slug == + state.chatRoom.users.receiver.slug, + ); if (isBlocked) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Вы не можете отправить сообщение заблокированному пользователю'), + content: Text( + 'Вы не можете отправить сообщение заблокированному пользователю', + ), backgroundColor: Colors.red, ), ); @@ -584,10 +764,15 @@ class _ChatScreenState extends State { } } - if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) { + if (textEditingController.text.isNotEmpty || + pickedFiles.isNotEmpty) { chatRoomCubit.sendMessage( - data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(), - files: pickedFiles.isEmpty ? null : pickedFiles, + data: textEditingController.text.isEmpty + ? null + : textEditingController.text.trim(), + files: pickedFiles.isEmpty + ? null + : pickedFiles, userSlug: state.chatRoom.users.receiver.slug, ); setState(() { @@ -597,10 +782,17 @@ class _ChatScreenState extends State { } }, child: Ink( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + decoration: BoxDecoration( + color: appTheme.blueA400, + shape: BoxShape.circle, + ), height: 40, width: 40, - child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white), + child: const Icon( + Icons.arrow_upward_rounded, + size: 24, + color: Colors.white, + ), ), ), const SizedBox(width: 8), @@ -619,12 +811,19 @@ class _ChatScreenState extends State { child: AboutOrderButton( onTap: () async { if (amIConsultant) { - GoRouter.of( - context, - ).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug}); + GoRouter.of(context).push( + InfoByOrderScreen.path, + extra: { + 'planId': state.chatRoom.chosenPlan?.id, + 'customerSlug': state.chatRoom.users.receiver.slug, + }, + ); return; } - GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id}); + GoRouter.of(context).push( + InfoByOrderScreen.path, + extra: {'planId': state.chatRoom.chosenPlan?.id}, + ); }, ), ), @@ -649,7 +848,13 @@ class _ChatScreenState extends State { const Icon(Icons.attach_file, size: 32, color: Colors.black54), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), + child: Text( + fileName, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), ), ], ), @@ -697,7 +902,13 @@ class _ChatScreenState extends State { // await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug); // } }, - child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), + child: const Row( + children: [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), ), CupertinoActionSheetAction( onPressed: () async { @@ -706,13 +917,21 @@ class _ChatScreenState extends State { List images = await picker.pickMultiImage(); if (images != []) { setState(() { - pickedFiles.addAll(images.map((image) => File(image.path)).toList()); + pickedFiles.addAll( + images.map((image) => File(image.path)).toList(), + ); }); // List files = images.map((image) => File(image.path)).toList(); // await chatRoomCubit.sendMessage(files: files, userSlug: slug); } }, - child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]), + child: const Row( + children: [ + Icon(Icons.photo_library_rounded), + SizedBox(width: 8), + Text('Галерея'), + ], + ), ), CupertinoActionSheetAction( onPressed: () async { @@ -744,15 +963,27 @@ class _ChatScreenState extends State { if (result != null) { // List files = result.paths.map((path) => File(path!)).toList(); setState(() { - pickedFiles.addAll(result.paths.map((path) => File(path!)).toList()); + pickedFiles.addAll( + result.paths.map((path) => File(path!)).toList(), + ); }); // await chatRoomCubit.sendMessage(files: files, userSlug: slug); } }, - child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]), + child: const Row( + children: [ + Icon(Icons.file_present_rounded), + SizedBox(width: 8), + Text('Файлы'), + ], + ), ), ], - cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(), + isDestructiveAction: true, + child: const Text('Отмена'), + ), ), ); } @@ -763,13 +994,21 @@ class _ChatScreenState extends State { padding: const EdgeInsets.only(top: 32), child: Text( DateFormat.yMMMMd('RU').format(date), - style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500), + style: const TextStyle( + height: 1, + color: Color(0xFF8393A1), + fontWeight: FontWeight.w500, + ), ), ), ); } - void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) { + void _openVideoCall( + BuildContext context, + ChatRoomStateLoaded state, + ChatUserModel chatCounterpart, + ) { GoRouter.of(context).pushNamed( VideoCallScreen.name, extra: { @@ -797,18 +1036,28 @@ class _ChatScreenState extends State { 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); - }); + 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({ @@ -819,11 +1068,13 @@ class _ChatScreenState extends State { required ChatMessageModel message, required bool? amIConsultant, }) { - if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) { + if (message.type == ChatMessageType.videoCallStarted && + !isCurrentUserAuthor) { return VideoCallChatMessageWidget( text: message.data.trim(), sendTime: message.updatedAt, - onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart), + onVideoCallPressed: () => + _openVideoCall(context, state, chatCounterpart), ); } @@ -862,7 +1113,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget { child: SizedBox( width: 40, height: 40, - child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)), + child: Center( + child: Icon( + Icons.videocam_rounded, + color: appTheme.blueA400, + size: 18, + ), + ), ), ), ); diff --git a/lib/features/create_plan/presentation/widgets/add_item_widget.dart b/lib/features/create_plan/presentation/widgets/add_item_widget.dart index e51cf977..7cddecea 100644 --- a/lib/features/create_plan/presentation/widgets/add_item_widget.dart +++ b/lib/features/create_plan/presentation/widgets/add_item_widget.dart @@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget { class _AddItemWidgetState extends State { final controller = TextEditingController(); + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override void didUpdateWidget(oldWidget) { super.didUpdateWidget(oldWidget); @@ -38,14 +44,24 @@ class _AddItemWidgetState extends State { @override Widget build(BuildContext context) { final s = S.of(context); - if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) { + if (widget.items.length > widget.index && + controller.text != widget.items[widget.index]) { controller.text = widget.items[widget.index]; } return BlocBuilder( - buildWhen: (previous, current) => previous.validationState != current.validationState, + buildWhen: (previous, current) => + previous.validationState != current.validationState, builder: (context, state) { - final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty || - (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false); + final hasError = + state + .validationState + .optionsValidationState[state.selectedOptionIndex ?? 0] + .isItemsEmpty || + (state + .validationState + .optionsValidationState[state.selectedOptionIndex ?? 0] + .itemsErrors[widget.index] ?? + false); return Column( children: [ Row( @@ -54,9 +70,14 @@ class _AddItemWidgetState extends State { child: CustomTextFormField( controller: controller, hasError: hasError, - hintText: widget.enableHint ? s.option_content_hint_text : null, + hintText: widget.enableHint + ? s.option_content_hint_text + : null, onChanged: (value) { - context.read().setOptionContent(value, widget.index); + context.read().setOptionContent( + value, + widget.index, + ); }, ), ), @@ -64,7 +85,9 @@ class _AddItemWidgetState extends State { const SizedBox(width: 8), GestureDetector( onTap: () { - context.read().removeOptionContentItem(widget.index); + context.read().removeOptionContentItem( + widget.index, + ); }, child: Container( padding: const EdgeInsets.all(14), @@ -72,19 +95,30 @@ class _AddItemWidgetState extends State { color: appTheme.lightGray0, borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24), + child: SvgPicture.asset( + 'assets/icons/trash_can.svg', + width: 20, + height: 24, + ), ), ), ], ], ), - if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[ + if (state + .validationState + .optionsValidationState[state.selectedOptionIndex ?? 0] + .isItemsEmpty) ...[ const SizedBox(height: 4), - ValidationErrorWidget(error: s.addAtLeastOneItem) - ] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[ + ValidationErrorWidget(error: s.addAtLeastOneItem), + ] else if (state + .validationState + .optionsValidationState[state.selectedOptionIndex ?? 0] + .itemsErrors[widget.index] ?? + false) ...[ const SizedBox(height: 4), - ValidationErrorWidget(error: s.atLeast3Symbols) - ] + ValidationErrorWidget(error: s.atLeast3Symbols), + ], ], ); }, diff --git a/lib/features/create_plan/presentation/widgets/option_widget.dart b/lib/features/create_plan/presentation/widgets/option_widget.dart index cc4eb816..29805d82 100644 --- a/lib/features/create_plan/presentation/widgets/option_widget.dart +++ b/lib/features/create_plan/presentation/widgets/option_widget.dart @@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget { } class _OptionWidgetState extends State { - final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽'; - final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽'; - final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽'; + static const _optionsCount = 3; - final creatorPriceController = TextEditingController()..text = '1000₽'; - - final firstOptionTitleController = TextEditingController(); - final secondOptionTitleController = TextEditingController(); - final thirdOptionTitleController = TextEditingController(); - - final firstOptionDescriptionController = TextEditingController(); - final secondOptionDescriptionController = TextEditingController(); - final thirdOptionDescriptionController = TextEditingController(); + final List _customerPriceControllers = List.generate( + _optionsCount, + (_) => TextEditingController(text: '1200₽'), + ); + final List _titleControllers = List.generate( + _optionsCount, + (_) => TextEditingController(), + ); + final List _descriptionControllers = List.generate( + _optionsCount, + (_) => TextEditingController(), + ); + final creatorPriceController = TextEditingController(text: '1000₽'); + int? _syncedOptionIndex; bool firstExplanationVisible = false; bool isChecked = false; bool secondExplanationVisible = false; + @override void initState() { super.initState(); - firstOptionCustomerPriceController.addListener(() { - if (!firstOptionCustomerPriceController.text.endsWith('₽')) { - final text = firstOptionCustomerPriceController.text.replaceAll('₽', ''); - firstOptionCustomerPriceController.text = '$text₽'; - firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length); - } - }); - secondOptionCustomerPriceController.addListener(() { - if (!secondOptionCustomerPriceController.text.endsWith('₽')) { - final text = secondOptionCustomerPriceController.text.replaceAll('₽', ''); - secondOptionCustomerPriceController.text = '$text₽'; - secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length); - } - }); - thirdOptionCustomerPriceController.addListener(() { - if (!thirdOptionCustomerPriceController.text.endsWith('₽')) { - final text = thirdOptionCustomerPriceController.text.replaceAll('₽', ''); - thirdOptionCustomerPriceController.text = '$text₽'; - thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length); - } - }); + for (final controller in _customerPriceControllers) { + controller.addListener(() => _ensureRubleSuffix(controller)); + } + } + + @override + void dispose() { + for (final controller in _customerPriceControllers) { + controller.dispose(); + } + for (final controller in _titleControllers) { + controller.dispose(); + } + for (final controller in _descriptionControllers) { + controller.dispose(); + } + creatorPriceController.dispose(); + super.dispose(); + } + + void _ensureRubleSuffix(TextEditingController controller) { + if (!controller.text.endsWith('₽')) { + final text = controller.text.replaceAll('₽', ''); + controller.text = '$text₽'; + controller.selection = TextSelection.collapsed(offset: text.length); + } + } + + void _syncController(TextEditingController controller, String value) { + if (controller.text == value) { + return; + } + controller.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + } + + String _formatCustomerPrice(dynamic price) { + if (price is num) { + return '${price.toStringAsFixed(0)}₽'; + } + final parsed = double.tryParse(price.toString().replaceAll('₽', '')); + return '${(parsed ?? 0).toStringAsFixed(0)}₽'; + } + + void _applyOptionToControllers(int index, PacketOptions option) { + _syncController(_titleControllers[index], option.short); + _syncController(_descriptionControllers[index], option.description ?? ''); + _syncController( + _customerPriceControllers[index], + _formatCustomerPrice(option.price), + ); + } + + void _syncSelectedOption(CreatePlanState state) { + final selectedOptionIndex = state.selectedOptionIndex ?? 0; + final option = state.planData.options[selectedOptionIndex]; + final price = double.tryParse( + _customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''), + ); + final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽'; + + if (_syncedOptionIndex != selectedOptionIndex) { + _syncedOptionIndex = selectedOptionIndex; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _applyOptionToControllers(selectedOptionIndex, option); + creatorPriceController.text = creatorPrice; + }); + return; + } + if (creatorPriceController.text != creatorPrice) { + creatorPriceController.text = creatorPrice; + } } @override @@ -70,31 +131,21 @@ class _OptionWidgetState extends State { return BlocBuilder( builder: (context, state) { final selectedOptionIndex = state.selectedOptionIndex ?? 0; + _syncSelectedOption(state); final optionNameHintText = switch (selectedOptionIndex) { 0 => s.economy, 2 => s.comfort, _ => s.standard, }; - final optionTitleController = switch (selectedOptionIndex) { - 0 => firstOptionTitleController, - 2 => thirdOptionTitleController, - _ => secondOptionTitleController, - }; - final optionDescriptionController = switch (selectedOptionIndex) { - 0 => firstOptionDescriptionController, - 2 => thirdOptionDescriptionController, - _ => secondOptionDescriptionController, - }; - final customerPriceController = switch (selectedOptionIndex) { - 0 => firstOptionCustomerPriceController, - 2 => thirdOptionCustomerPriceController, - _ => secondOptionCustomerPriceController, - }; + final optionTitleController = _titleControllers[selectedOptionIndex]; + final optionDescriptionController = + _descriptionControllers[selectedOptionIndex]; + final customerPriceController = + _customerPriceControllers[selectedOptionIndex]; + final optionItems = state.planData.options[selectedOptionIndex].items; + final optionValidation = + state.validationState.optionsValidationState[selectedOptionIndex]; - final price = double.tryParse(customerPriceController.text.replaceAll('₽', '')); - creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽'; - firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short; - firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? ''; return MainBackgroundBouble( padding: const EdgeInsets.all(16), child: Column( @@ -110,14 +161,14 @@ class _OptionWidgetState extends State { const SizedBox(height: 4), CustomTextFormField( hintText: optionNameHintText, - hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError, + hasError: optionValidation.titleHasError, controller: optionTitleController, onChanged: (value) { context.read().setOptionTitle(value); }, maxLength: 150, ), - if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[ + if (optionValidation.titleHasError) ...[ const SizedBox(height: 4), ValidationErrorWidget(error: s.atLeast3Symbols), ], @@ -135,7 +186,7 @@ class _OptionWidgetState extends State { child: CustomTextFormField( controller: optionDescriptionController, textAlignVertical: TextAlignVertical.top, - hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError, + hasError: optionValidation.descriptionHasError, maxLength: 1000, minLines: null, maxLines: null, @@ -146,7 +197,7 @@ class _OptionWidgetState extends State { }, ), ), - if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[ + if (optionValidation.descriptionHasError) ...[ const SizedBox(height: 4), ValidationErrorWidget(error: s.atLeast3Symbols), ], @@ -158,7 +209,9 @@ class _OptionWidgetState extends State { textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, softWrap: true, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), + style: TextStyles.textSMedium.copyWith( + color: appTheme.black, + ), ), const SizedBox(width: 4), const _PriceExplanationWidget(), @@ -179,7 +232,9 @@ class _OptionWidgetState extends State { textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, softWrap: true, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), + style: TextStyles.textSMedium.copyWith( + color: appTheme.black, + ), ), const SizedBox(width: 4), const _PriceExplanationWidget(), @@ -188,8 +243,10 @@ class _OptionWidgetState extends State { const SizedBox(height: 4), CustomTextFormField( controller: customerPriceController, - textInputType: const TextInputType.numberWithOptions(decimal: true), - hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError, + textInputType: const TextInputType.numberWithOptions( + decimal: true, + ), + hasError: optionValidation.priceHasError, onChanged: (value) { final price = double.tryParse(value.replaceAll('₽', '')); if (price != null) { @@ -222,34 +279,41 @@ class _OptionWidgetState extends State { separatorBuilder: (context, index) => const SizedBox(height: 8), reverse: false, itemBuilder: (context, index) { - final items = state.planData.options[selectedOptionIndex].items; - - if (items.isEmpty) { + if (optionItems.isEmpty) { return AddItemWidget( + key: ValueKey( + 'plan-option-$selectedOptionIndex-item-$index', + ), index: index, canBeDeleted: false, enableHint: true, - items: items, + items: optionItems, ); } else { - if (index < items.length) { + if (index < optionItems.length) { return AddItemWidget( + key: ValueKey( + 'plan-option-$selectedOptionIndex-item-$index', + ), canBeDeleted: true, enableHint: false, index: index, - items: items, + items: optionItems, ); } else { return AddItemWidget( + key: ValueKey( + 'plan-option-$selectedOptionIndex-item-$index', + ), canBeDeleted: false, enableHint: false, index: index, - items: items, + items: optionItems, ); } } }, - itemCount: state.planData.options[selectedOptionIndex].items.length + 1, + itemCount: optionItems.length + 1, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), ), @@ -259,23 +323,30 @@ class _OptionWidgetState extends State { Row( children: [ Checkbox( - value: state.planData.options[selectedOptionIndex].hasPersonalDiet, + value: state + .planData + .options[selectedOptionIndex] + .hasPersonalDiet, activeColor: appTheme.gray, checkColor: appTheme.gray, fillColor: const WidgetStatePropertyAll(Colors.transparent), - side: WidgetStateBorderSide.resolveWith( - (Set states) { - return BorderSide(width: 1, color: appTheme.gray); - }, - ), + side: WidgetStateBorderSide.resolveWith(( + Set states, + ) { + return BorderSide(width: 1, color: appTheme.gray); + }), onChanged: (bool? value) { - context.read().togglePersonalizedDietOptionState(); + context + .read() + .togglePersonalizedDietOptionState(); }, ), Expanded( child: Text( s.option_personal_diet_desc, - style: TextStyles.textMMedium.copyWith(color: appTheme.black), + style: TextStyles.textMMedium.copyWith( + color: appTheme.black, + ), ), ), ], @@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget { const _PriceExplanationWidget(); @override - State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState(); + State<_PriceExplanationWidget> createState() => + _PriceExplanationWidgetState(); } class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> { @@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> { color: visible ? appTheme.whiteA700 : Colors.transparent, ), duration: const Duration(milliseconds: 300), - child: Text( - s.price_explanation, - ), + child: Text(s.price_explanation), ), ), visible: visible, @@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> { visible = !visible; }); }, - child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12), + child: SvgPicture.asset( + 'assets/icons/question_mark.svg', + height: 12, + width: 12, + ), ), ); } diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 12832204..a11d923b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -20,7 +20,8 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:livekit_client/livekit_client.dart' as lk; import 'package:permission_handler/permission_handler.dart'; -typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapshot); +typedef VideoCallManagerListener = + void Function(VideoCallManagerSnapshot snapshot); class VideoCallManager { VideoCallManager({ @@ -158,9 +159,7 @@ class VideoCallManager { return lk.CameraCaptureOptions( cameraPosition: cameraPosition ?? - (_useFrontCamera - ? lk.CameraPosition.front - : lk.CameraPosition.back), + (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back), params: lk.VideoParametersPresets.h1080_169, ); } @@ -286,10 +285,11 @@ class VideoCallManager { if (_liveKitDisposed) return false; _callStageEntered = true; + await _startCallAudioSession(); + if (_liveKitDisposed || !_callStageEntered) return false; await _publishLocalMedia(); if (_liveKitDisposed || !_callStageEntered) return false; await _startForegroundIfPossible(); - await _startCallAudioSession(); return true; } catch (e) { _emitSnapshot( @@ -352,8 +352,7 @@ class VideoCallManager { return; } final room = _room; - if (room == null || - room.connectionState != lk.ConnectionState.connected) { + if (room == null || room.connectionState != lk.ConnectionState.connected) { return; } if (room.remoteParticipants.isEmpty) return; @@ -469,13 +468,20 @@ class VideoCallManager { if (next) { final granted = await _ensureMicrophonePermission(); if (!granted) return; + await _startCallAudioSession(); + } + final participant = _room!.localParticipant; + if (participant == null) return; + try { + await participant.setMicrophoneEnabled( + next, + audioCaptureOptions: _micCaptureOptions, + ); + } catch (_) { + return; } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; - await _room!.localParticipant?.setMicrophoneEnabled( - next, - audioCaptureOptions: _micCaptureOptions, - ); } Future toggleVideo(bool currentEnabled) async { @@ -536,9 +542,7 @@ class VideoCallManager { try { await lp.setCameraEnabled( true, - cameraCaptureOptions: cameraCaptureOptions( - cameraPosition: newPosition, - ), + cameraCaptureOptions: cameraCaptureOptions(cameraPosition: newPosition), ); } catch (_) {} } @@ -663,6 +667,15 @@ class VideoCallManager { Future _onAppResumed() async { if (!isActiveCall) return; + await _startCallAudioSession(); + if (_snapshot.micEnabled) { + try { + await _room?.localParticipant?.setMicrophoneEnabled( + true, + audioCaptureOptions: _micCaptureOptions, + ); + } catch (_) {} + } await _resumeVideoAfterForeground(); } @@ -670,12 +683,10 @@ class VideoCallManager { if (!isActiveCall) return; final ctx = _navigatorContext; final s = ctx != null ? S.of(ctx) : null; - final title = - resolvedRemoteDisplayName( - s?.video_call_counterpart_default ?? 'Call', - ); - final body = - s?.video_call_notification_in_progress ?? 'Call in progress'; + final title = resolvedRemoteDisplayName( + s?.video_call_counterpart_default ?? 'Call', + ); + final body = s?.video_call_notification_in_progress ?? 'Call in progress'; final endLabel = s?.video_call_notification_end ?? 'End call'; await _foreground.startOngoingCall( conversationUuid: _snapshot.conversationUuid, @@ -739,11 +750,7 @@ class VideoCallManager { _roomConnectFuture = null; _clearPreparedConnectionCredentials(); _callAcceptNotifiedConversationUuids.clear(); - _emitSnapshot( - _snapshot.copyWith( - counterpartPresentInLobby: false, - ), - ); + _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false)); } void _clearPreparedConnectionCredentials() { @@ -925,10 +932,7 @@ class VideoCallManager { _preparedWsUrl = tokenResponse.wsUrl; _preparedToken = tokenResponse.token; - await room.prepareConnection( - tokenResponse.wsUrl, - tokenResponse.token, - ); + await room.prepareConnection(tokenResponse.wsUrl, tokenResponse.token); if (_liveKitDisposed) return; _emitSnapshot( @@ -977,8 +981,7 @@ class VideoCallManager { _syncRemoteParticipantNameFromRoom(); _syncPoorConnectionOverlay( - room.localParticipant?.connectionQuality ?? - lk.ConnectionQuality.unknown, + room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown, ); } @@ -1002,8 +1005,6 @@ class VideoCallManager { status.isPermanentlyDenied || status.isRestricted; Future _ensureCameraPermission() async { - final ctx = _navigatorContext; - if (ctx == null) return false; var status = await Permission.camera.status; if (status.isGranted) return true; status = await Permission.camera.request(); @@ -1023,8 +1024,6 @@ class VideoCallManager { } Future _ensureMicrophonePermission() async { - final ctx = _navigatorContext; - if (ctx == null) return false; var status = await Permission.microphone.status; if (status.isGranted) return true; status = await Permission.microphone.request(); @@ -1136,4 +1135,4 @@ class VideoCallManager { await _audioSession.stop(); } catch (_) {} } -} \ No newline at end of file +} diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index c35b9b34..28cc47b0 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -66,10 +66,9 @@ class VideoCallSessionCubit extends BaseCubit { connectPhase: snapshot.connectPhase, linkIssue: snapshot.linkIssue, callStageEntered: resetCallStage ? false : state.callStageEntered, - uiMode: - _isCallSessionInactive(snapshot) - ? VideoCallUiMode.hidden - : state.uiMode, + uiMode: _isCallSessionInactive(snapshot) + ? VideoCallUiMode.hidden + : state.uiMode, ); } @@ -364,8 +363,6 @@ class VideoCallSessionCubit extends BaseCubit { if (!granted || isClosed) return; await _videoCallManager.toggleMic(state.isMicActiveForUi); - if (isClosed) return; - emit(state.copyWith(micEnabled: true)); } @visibleForTesting diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index 55c2477d..9297f696 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -1,43 +1,65 @@ -import 'dart:async'; +import 'dart:io'; import 'package:audio_session/audio_session.dart'; +import 'package:flutter/services.dart'; typedef VideoCallAudioSessionFactory = Future Function(); +typedef IosCallAudioSessionHandler = Future Function(String method); /// Configures and owns the platform audio session for an active VoIP call. class VideoCallAudioSessionCoordinator { VideoCallAudioSessionCoordinator({ VideoCallAudioSessionFactory? audioSessionFactory, + bool? isIos, + IosCallAudioSessionHandler? iosCallAudioSession, }) : _audioSessionFactory = - audioSessionFactory ?? (() => AudioSession.instance); + audioSessionFactory ?? (() => AudioSession.instance), + _isIos = isIos ?? Platform.isIOS, + _iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession; + + static const MethodChannel _iosChannel = MethodChannel( + 'com.eatvioapp/call_audio_session', + ); final VideoCallAudioSessionFactory _audioSessionFactory; + final bool _isIos; + final IosCallAudioSessionHandler _iosCallAudioSession; static final AudioSessionConfiguration callConfiguration = AudioSessionConfiguration( - avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, - avAudioSessionCategoryOptions: - AVAudioSessionCategoryOptions.allowBluetooth | + avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, + avAudioSessionCategoryOptions: + AVAudioSessionCategoryOptions.allowBluetooth | AVAudioSessionCategoryOptions.defaultToSpeaker, - avAudioSessionMode: AVAudioSessionMode.voiceChat, - avAudioSessionRouteSharingPolicy: - AVAudioSessionRouteSharingPolicy.defaultPolicy, - avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, - androidAudioAttributes: const AndroidAudioAttributes( - contentType: AndroidAudioContentType.speech, - flags: AndroidAudioFlags.none, - usage: AndroidAudioUsage.voiceCommunication, - ), - androidAudioFocusGainType: AndroidAudioFocusGainType.gain, - androidWillPauseWhenDucked: true, - ); + avAudioSessionMode: AVAudioSessionMode.voiceChat, + avAudioSessionRouteSharingPolicy: + AVAudioSessionRouteSharingPolicy.defaultPolicy, + avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, + androidAudioAttributes: const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + flags: AndroidAudioFlags.none, + usage: AndroidAudioUsage.voiceCommunication, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + androidWillPauseWhenDucked: true, + ); AudioSession? _session; bool _configured = false; + bool _iosActive = false; - bool get isActive => _session != null; + bool get isActive => _isIos ? _iosActive : _session != null; + + static Future _invokeIosCallAudioSession(String method) { + return _iosChannel.invokeMethod(method); + } Future start() async { + if (_isIos) { + await _iosCallAudioSession('activate'); + _iosActive = true; + return; + } final session = await _audioSessionFactory(); _session = session; if (!_configured) { @@ -48,6 +70,15 @@ class VideoCallAudioSessionCoordinator { } Future reactivate() async { + if (_isIos) { + try { + await _iosCallAudioSession('activate'); + _iosActive = true; + return true; + } catch (_) { + return false; + } + } final session = _session; if (session == null) return false; try { @@ -58,6 +89,13 @@ class VideoCallAudioSessionCoordinator { } Future stop() async { + if (_isIos) { + _iosActive = false; + try { + await _iosCallAudioSession('deactivate'); + } catch (_) {} + return; + } final session = _session; _session = null; if (session == null) return; diff --git a/lib/features/welcome/presentation/pages/welcome_screen.dart b/lib/features/welcome/presentation/pages/welcome_screen.dart index c7a815dd..e6f48dc9 100644 --- a/lib/features/welcome/presentation/pages/welcome_screen.dart +++ b/lib/features/welcome/presentation/pages/welcome_screen.dart @@ -44,82 +44,112 @@ class _WelcomeScreenState extends State { ), Positioned.fill( child: SafeArea( - child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 80), - SvgPicture.asset('assets/images/logo_welcome.svg'), - const Spacer(), - Padding( - padding: const EdgeInsets.all(16), - child: Material( - borderRadius: BorderRadius.circular(14), - color: Colors.white, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - const Text( - 'Добро пожаловать!', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - height: 1, - ), - ), - const SizedBox(height: 20), - const Text( - 'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.15, - ), - ), - const SizedBox(height: 24), - InkWell( - key: AppKeys.welcomeScreenButton, - onTap: () async { - final sharedPreferences = sl(); - await sharedPreferences.setBool(PrefsKeys.welcomeScreenWasShowed, true); - if (await TokenStorage.haveTokenInStorage()) { - goRouter.go('/feed-screen'); - } else { - goRouter.go('/auth-screen'); - } - }, - borderRadius: BorderRadius.circular(8), - child: Ink( - height: 52, - decoration: BoxDecoration( - color: appTheme.blueA400, - borderRadius: BorderRadius.circular(8), - ), - child: const Center( - child: Text( - 'Начать пользоваться', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1, - color: Colors.white, + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + final topGap = textScale > 1.2 ? 24.0 : 80.0; + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 16, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: topGap), + SvgPicture.asset('assets/images/logo_welcome.svg'), + const Spacer(), + Padding( + padding: const EdgeInsets.all(16), + child: Material( + borderRadius: BorderRadius.circular(14), + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + const Text( + 'Добро пожаловать!', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + height: 1, + ), ), - ), + const SizedBox(height: 20), + const Text( + 'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.15, + ), + ), + const SizedBox(height: 24), + InkWell( + key: AppKeys.welcomeScreenButton, + onTap: () async { + final sharedPreferences = sl(); + await sharedPreferences.setBool( + PrefsKeys.welcomeScreenWasShowed, + true, + ); + if (await TokenStorage.haveTokenInStorage()) { + goRouter.go('/feed-screen'); + } else { + goRouter.go('/auth-screen'); + } + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + color: appTheme.blueA400, + borderRadius: BorderRadius.circular( + 8, + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 52, + ), + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: Center( + child: Text( + 'Начать пользоваться', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + ], ), ), ), - const SizedBox(height: 16), - ], - ), + ), + ], ), ), ), - ], - ), + ); + }, ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 403b6ed4..67488f48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.45+135 +version: 1.3.46+135 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 dc25c93b..7250b6d1 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -3,7 +3,8 @@ import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_meta_model.dart'; -import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room; +import 'package:eatvio_app/features/chat/models/chat_room_model.dart' + as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_settings_model.dart'; import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart'; @@ -40,10 +41,23 @@ void main() { const testChatRoom = chat_room.ChatRoomModel( id: 'chat-id', - chatRoomSettings: ChatRoomSettingsModel(name: 'Test Room', state: 'active'), + chatRoomSettings: ChatRoomSettingsModel( + name: 'Test Room', + state: 'active', + ), users: chat_room.Users( - sender: ChatUserModel(id: 1, slug: 'user-1', name: 'User 1', profilePhotoUrl: null), - receiver: ChatUserModel(id: 2, slug: 'user-2', name: 'User 2', profilePhotoUrl: null), + sender: ChatUserModel( + id: 1, + slug: 'user-1', + name: 'User 1', + profilePhotoUrl: null, + ), + receiver: ChatUserModel( + id: 2, + slug: 'user-2', + name: 'User 2', + profilePhotoUrl: null, + ), ), chosenPlanId: null, ); @@ -68,7 +82,11 @@ void main() { mockChatRepository = MockChatRepository(); mockCardsRepository = MockCardsRepository(); mockWsManager = MockWsManager(); - chatRoomCubit = ChatRoomCubit(chatRepository: mockChatRepository, wsManager: mockWsManager, cardsRepository: mockCardsRepository); + chatRoomCubit = ChatRoomCubit( + chatRepository: mockChatRepository, + wsManager: mockWsManager, + cardsRepository: mockCardsRepository, + ); }); tearDown(() async { @@ -84,14 +102,22 @@ void main() { test('initChat - успешно инициализирует чат', () async { const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); // initChat uses uuid (not chatRoom.id) to get messages - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); // Mock websocket client and streams - return null to skip websocket setup when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -104,77 +130,190 @@ void main() { verify(mockChatRepository.getChatRoom(uuid)).called(1); verify(mockChatRepository.getChatMessages(uuid, 1)).called(1); verifyNever( - mockChatRepository.resolveOrderIdToConversation(orderId: anyNamed('orderId')), + mockChatRepository.resolveOrderIdToConversation( + orderId: anyNamed('orderId'), + ), ); }); - test('initChat - с orderId резолвит conversation и инициализирует чат', () async { - const orderId = 'alfa_c29_k15138_t1712345678_r1234'; - const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872'; - const chosenPlanId = 12; - final resolved = OrderIdToConversationModel( - chosenPlanId: chosenPlanId, - conversationUuid: conversationUuid, - ); - final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: chosenPlanId); - 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: chosenPlanId, - optionType: 'options1', - startDate: DateTime.now(), - chosenPlanState: ChosenPlanStateEnum.statePendingPayment, - chatId: 'chat-1', - consultantId: 2, - plan: testPlan, - ); - + test('refreshChat - обновляет сообщения без перехода в loading', () async { + const uuid = 'room-uuid'; when( - mockChatRepository.resolveOrderIdToConversation(orderId: orderId), - ).thenAnswer((_) async => resolved); - when(mockChatRepository.getChatRoom(conversationUuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(conversationUuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: chosenPlanId)).thenAnswer((_) async => chosenPlan); + 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.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); - await chatRoomCubit.initChat(null, orderId: orderId); - await Future.delayed(const Duration(milliseconds: 200)); - - verify(mockChatRepository.resolveOrderIdToConversation(orderId: orderId)).called(1); - verify(mockChatRepository.getChatRoom(conversationUuid)).called(1); - verify(mockChatRepository.getChosenPlan(id: chosenPlanId)).called(1); - verify(mockChatRepository.getChatMessages(conversationUuid, 1)).called(1); + await chatRoomCubit.initChat(uuid); expect(chatRoomCubit.state, isA()); - final loaded = chatRoomCubit.state as ChatRoomStateLoaded; - expect(loaded.chatRoom.chosenPlan?.id, chosenPlanId); + + final refreshedMessages = ChatMessagesModel( + messagesList: [ + ChatMessageModel( + id: 2, + data: 'Refreshed message', + mediaData: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: true, + roomId: 'chat-id', + ), + ], + meta: const ChatMetaModel(lastPage: 1), + ); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => refreshedMessages); + + await chatRoomCubit.refreshChat(); + + final state = chatRoomCubit.state; + expect(state, isA()); + expect( + (state as ChatRoomStateLoaded).chatMessages.first.data, + 'Refreshed message', + ); + verify(mockChatRepository.getChatMessages(uuid, 1)).called(2); + verify(mockChatRepository.getChatRoom(uuid)).called(2); }); + test('refreshChat - при ошибке оставляет текущие сообщения', () 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); + + await chatRoomCubit.initChat(uuid); + when( + mockChatRepository.getChatRoom(uuid), + ).thenThrow(Exception('Network error')); + + await chatRoomCubit.refreshChat(); + + final state = chatRoomCubit.state; + expect(state, isA()); + expect( + (state as ChatRoomStateLoaded).chatMessages.first.data, + 'Test message', + ); + }); + + test('refreshChat - ничего не делает, если чат ещё не загружен', () async { + await chatRoomCubit.refreshChat(); + + verifyNever(mockChatRepository.getChatRoom(any)); + verifyNever(mockChatRepository.getChatMessages(any, any)); + expect(chatRoomCubit.state, isA()); + }); + + test( + 'initChat - с orderId резолвит conversation и инициализирует чат', + () async { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872'; + const chosenPlanId = 12; + final resolved = OrderIdToConversationModel( + chosenPlanId: chosenPlanId, + conversationUuid: conversationUuid, + ); + final chatRoomWithPlan = testChatRoom.copyWith( + chosenPlanId: chosenPlanId, + ); + 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: chosenPlanId, + optionType: 'options1', + startDate: DateTime.now(), + chosenPlanState: ChosenPlanStateEnum.statePendingPayment, + chatId: 'chat-1', + consultantId: 2, + plan: testPlan, + ); + + when( + mockChatRepository.resolveOrderIdToConversation(orderId: orderId), + ).thenAnswer((_) async => resolved); + when( + mockChatRepository.getChatRoom(conversationUuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(conversationUuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: chosenPlanId), + ).thenAnswer((_) async => chosenPlan); + 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(null, orderId: orderId); + await Future.delayed(const Duration(milliseconds: 200)); + + verify( + mockChatRepository.resolveOrderIdToConversation(orderId: orderId), + ).called(1); + verify(mockChatRepository.getChatRoom(conversationUuid)).called(1); + verify(mockChatRepository.getChosenPlan(id: chosenPlanId)).called(1); + verify( + mockChatRepository.getChatMessages(conversationUuid, 1), + ).called(1); + expect(chatRoomCubit.state, isA()); + final loaded = chatRoomCubit.state as ChatRoomStateLoaded; + expect(loaded.chatRoom.chosenPlan?.id, chosenPlanId); + }, + ); + test('initChat - при ошибке resolve не вызывает getChatRoom', () async { const orderId = 'alfa_c29_k15138_t1712345678_r1234'; when( @@ -190,7 +329,9 @@ void main() { test('initChat - обрабатывает ошибку', () async { const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenThrow(Exception('Network error')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenThrow(Exception('Network error')); await chatRoomCubit.initChat(uuid); @@ -203,10 +344,16 @@ void main() { final messagesIds = [1]; // Сначала инициализируем чат - when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(any, any)).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChatRoom(any), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(any, any), + ).thenAnswer((_) async => testChatMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -214,9 +361,14 @@ void main() { await chatRoomCubit.initChat('uuid'); await Future.delayed(const Duration(milliseconds: 100)); - when(mockChatRepository.markMessagesAsRead(chatId, [1])).thenAnswer((_) async => Future.value()); + when( + mockChatRepository.markMessagesAsRead(chatId, [1]), + ).thenAnswer((_) async => Future.value()); - final result = await chatRoomCubit.markMessagesAsRead(chatId, messagesIds); + final result = await chatRoomCubit.markMessagesAsRead( + chatId, + messagesIds, + ); expect(result, true); verify(mockChatRepository.markMessagesAsRead(chatId, [1])).called(1); @@ -224,10 +376,16 @@ void main() { test('getPaymentLink - получает ссылку на оплату', () async { // Сначала инициализируем чат - when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(any, any)).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChatRoom(any), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(any, any), + ).thenAnswer((_) async => testChatMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -245,10 +403,16 @@ void main() { const uuid = 'message-uuid'; // Сначала инициализируем чат - when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(any, any)).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChatRoom(any), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(any, any), + ).thenAnswer((_) async => testChatMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -320,20 +484,30 @@ void main() { ); // Сначала инициализируем чат - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => initialMessages); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => initialMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + 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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); // Теперь загружаем следующую страницу - when(mockChatRepository.getChatMessages(uuid, 2)).thenAnswer((_) async => nextPageMessages); + when( + mockChatRepository.getChatMessages(uuid, 2), + ).thenAnswer((_) async => nextPageMessages); await chatRoomCubit.loadNextPage(uuid); await Future.delayed(const Duration(milliseconds: 200)); @@ -360,14 +534,22 @@ void main() { ); // Сначала инициализируем чат - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => initialMessages); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => initialMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + 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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); @@ -393,14 +575,22 @@ void main() { test('checkCards - проверяет наличие карт', () async { // Сначала инициализируем чат - when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(any, 1)).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChatRoom(any), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(any, 1), + ).thenAnswer((_) async => testChatMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + 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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat('uuid'); await Future.delayed(const Duration(milliseconds: 200)); @@ -414,81 +604,122 @@ void main() { verify(mockCardsRepository.getCards()).called(1); }); - 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, - ); + 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, + ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - 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.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - // Обновляем план - final updatedPlan = chosenPlan.copyWith(chosenPlanState: ChosenPlanStateEnum.statePaid); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => updatedPlan); + // Обновляем план + final updatedPlan = chosenPlan.copyWith( + chosenPlanState: ChosenPlanStateEnum.statePaid, + ); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => updatedPlan); - await chatRoomCubit.updateChosenPlanState(); - await Future.delayed(const Duration(milliseconds: 100)); + await chatRoomCubit.updateChosenPlanState(); + await Future.delayed(const Duration(milliseconds: 100)); - verify(mockChatRepository.getChosenPlan(id: planId)).called(greaterThan(1)); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.chatRoom.chosenPlan?.chosenPlanState, ChosenPlanStateEnum.statePaid); - }); + verify( + mockChatRepository.getChosenPlan(id: planId), + ).called(greaterThan(1)); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect( + state.chatRoom.chosenPlan?.chosenPlanState, + ChosenPlanStateEnum.statePaid, + ); + }, + ); - test('updateChosenPlanState - не обновляет если нет chosenPlanId', () async { - const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); - 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); + test( + 'updateChosenPlanState - не обновляет если нет chosenPlanId', + () async { + const uuid = 'room-uuid'; + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - await chatRoomCubit.updateChosenPlanState(); + await chatRoomCubit.updateChosenPlanState(); - verifyNever(mockChatRepository.getChosenPlan(id: anyNamed('id'))); - }); + verifyNever(mockChatRepository.getChosenPlan(id: anyNamed('id'))); + }, + ); test('choseStartPlanDate - устанавливает дату начала плана', () async { const uuid = 'room-uuid'; @@ -501,10 +732,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -524,12 +765,25 @@ void main() { ); final updatedPlan = chosenPlan.copyWith(startDate: DateTime(2024, 1, 15)); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.choseStartPlanDate(id: planId, date: anyNamed('date'))).thenAnswer((_) async => {}); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.choseStartPlanDate( + id: planId, + date: anyNamed('date'), + ), + ).thenAnswer((_) async => {}); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -537,27 +791,49 @@ void main() { await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => updatedPlan); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => updatedPlan); - final result = await chatRoomCubit.choseStartPlanDate(DateTime(2024, 1, 15)); + final result = await chatRoomCubit.choseStartPlanDate( + DateTime(2024, 1, 15), + ); expect(result, true); - verify(mockChatRepository.choseStartPlanDate(id: planId, date: anyNamed('date'))).called(1); - verify(mockChatRepository.getChosenPlan(id: planId)).called(greaterThan(1)); + verify( + mockChatRepository.choseStartPlanDate( + id: planId, + date: anyNamed('date'), + ), + ).called(1); + verify( + mockChatRepository.getChosenPlan(id: planId), + ).called(greaterThan(1)); }); - test('choseStartPlanDate - возвращает false если state не loaded', () async { - final result = await chatRoomCubit.choseStartPlanDate(DateTime.now()); - expect(result, false); - }); + test( + 'choseStartPlanDate - возвращает false если state не loaded', + () async { + final result = await chatRoomCubit.choseStartPlanDate(DateTime.now()); + expect(result, false); + }, + ); test('choseStartPlanDate - возвращает false если нет planId', () async { const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -580,10 +856,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -602,12 +888,22 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.cancelChosenPlan(id: planId)).thenAnswer((_) async => {}); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.cancelChosenPlan(id: planId), + ).thenAnswer((_) async => {}); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -634,10 +930,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -656,12 +962,22 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.cancelChosenPlan(id: planId)).thenThrow(Exception('Error')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.cancelChosenPlan(id: planId), + ).thenThrow(Exception('Error')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -675,108 +991,173 @@ void main() { expect(chatRoomCubit.state, isA()); }); - test('checkCards - устанавливает hasCard если есть карта с isDefault', () 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')); + test( + 'checkCards - устанавливает hasCard если есть карта с isDefault', + () 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - // Мокаем карту с isDefault = true - final cardWithDefault = CardModel(id: 1, cardNumber: '1234', isDefault: true); - when(mockCardsRepository.getCards()).thenAnswer((_) async => [cardWithDefault]); + // Мокаем карту с isDefault = true + final cardWithDefault = CardModel( + id: 1, + cardNumber: '1234', + isDefault: true, + ); + when( + mockCardsRepository.getCards(), + ).thenAnswer((_) async => [cardWithDefault]); - await chatRoomCubit.checkCards(); - await Future.delayed(const Duration(milliseconds: 100)); + await chatRoomCubit.checkCards(); + await Future.delayed(const Duration(milliseconds: 100)); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.hasCard, true); - }); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.hasCard, true); + }, + ); - test('checkCards - не устанавливает hasCard если нет карты с isDefault', () 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')); + test( + 'checkCards - не устанавливает hasCard если нет карты с isDefault', + () 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - // Мокаем карту с isDefault = false - final cardWithoutDefault = CardModel(id: 1, cardNumber: '1234', isDefault: false); - when(mockCardsRepository.getCards()).thenAnswer((_) async => [cardWithoutDefault]); + // Мокаем карту с isDefault = false + final cardWithoutDefault = CardModel( + id: 1, + cardNumber: '1234', + isDefault: false, + ); + when( + mockCardsRepository.getCards(), + ).thenAnswer((_) async => [cardWithoutDefault]); - await chatRoomCubit.checkCards(); - await Future.delayed(const Duration(milliseconds: 100)); + await chatRoomCubit.checkCards(); + await Future.delayed(const Duration(milliseconds: 100)); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.hasCard, false); - }); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.hasCard, false); + }, + ); - test('getPaymentLink - возвращает ссылку на оплату когда есть chosenPlan', () 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.statePendingPayment, - chatId: 'chat-1', - consultantId: 1, - plan: testPlan, - ); - const paymentUrl = 'https://example.com/payment'; + test( + 'getPaymentLink - возвращает ссылку на оплату когда есть chosenPlan', + () 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.statePendingPayment, + chatId: 'chat-1', + consultantId: 1, + plan: testPlan, + ); + const paymentUrl = 'https://example.com/payment'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); - 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.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.getPaymentLink( + id: planId, + paymentProvider: 'alfa-bank', + ), + ).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - final result = await chatRoomCubit.getPaymentLink(); + final result = await chatRoomCubit.getPaymentLink(); - expect(result, paymentUrl); - verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).called(1); - }); + expect(result, paymentUrl); + verify( + mockChatRepository.getPaymentLink( + id: planId, + paymentProvider: 'alfa-bank', + ), + ).called(1); + }, + ); test('getPaymentLink - обрабатывает ошибку', () async { const uuid = 'room-uuid'; @@ -789,10 +1170,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -811,12 +1202,25 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenThrow(Exception('Payment error')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.getPaymentLink( + id: planId, + paymentProvider: 'alfa-bank', + ), + ).thenThrow(Exception('Payment error')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -829,77 +1233,123 @@ void main() { expect(result, null); }); - test('complainAboutConsultation - отправляет жалобу на консультацию', () 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, - ); - const description = 'Test complaint'; + test( + 'complainAboutConsultation - отправляет жалобу на консультацию', + () 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, + ); + const description = 'Test complaint'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.complainAboutConsultation(id: planId, body: anyNamed('body'))).thenAnswer((_) async => Future.value()); - 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.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.complainAboutConsultation( + id: planId, + body: anyNamed('body'), + ), + ).thenAnswer((_) async => Future.value()); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - final result = await chatRoomCubit.complainAboutConsultation(description: description); + final result = await chatRoomCubit.complainAboutConsultation( + description: description, + ); - expect(result, true); - verify(mockChatRepository.complainAboutConsultation(id: planId, body: anyNamed('body'))).called(1); - }); + expect(result, true); + verify( + mockChatRepository.complainAboutConsultation( + id: planId, + body: anyNamed('body'), + ), + ).called(1); + }, + ); - test('complainAboutConsultation - возвращает false если нет planId', () async { - const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); - 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); + test( + 'complainAboutConsultation - возвращает false если нет planId', + () async { + const uuid = 'room-uuid'; + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - final result = await chatRoomCubit.complainAboutConsultation(description: 'Test'); + final result = await chatRoomCubit.complainAboutConsultation( + description: 'Test', + ); - expect(result, false); - }); + expect(result, false); + }, + ); test('complainAboutConsultation - обрабатывает ошибку', () async { const uuid = 'room-uuid'; @@ -912,10 +1362,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -934,12 +1394,25 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.complainAboutConsultation(id: planId, body: anyNamed('body'))).thenThrow(Exception('Error')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.complainAboutConsultation( + id: planId, + body: anyNamed('body'), + ), + ).thenThrow(Exception('Error')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -947,7 +1420,9 @@ void main() { await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); - final result = await chatRoomCubit.complainAboutConsultation(description: 'Test'); + final result = await chatRoomCubit.complainAboutConsultation( + description: 'Test', + ); expect(result, false); }); @@ -963,10 +1438,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -985,12 +1470,22 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.finishChosenPlan(id: planId)).thenAnswer((_) async => Future.value()); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.finishChosenPlan(id: planId), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -1006,11 +1501,19 @@ void main() { test('finishChosenPlan - возвращает false если нет planId', () async { const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -1034,10 +1537,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -1056,12 +1569,22 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.finishChosenPlan(id: planId)).thenThrow(Exception('Error')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.finishChosenPlan(id: planId), + ).thenThrow(Exception('Error')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -1092,19 +1615,29 @@ void main() { meta: const ChatMetaModel(lastPage: 2), ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => initialMessages); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => initialMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + 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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); - when(mockChatRepository.getChatMessages(uuid, 2)).thenThrow(Exception('Error')); + when( + mockChatRepository.getChatMessages(uuid, 2), + ).thenThrow(Exception('Error')); await chatRoomCubit.loadNextPage(uuid); await Future.delayed(const Duration(milliseconds: 100)); @@ -1130,14 +1663,22 @@ void main() { meta: const ChatMetaModel(lastPage: 1), ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => initialMessages); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => initialMessages); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + 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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); @@ -1149,139 +1690,201 @@ void main() { verifyNever(mockChatRepository.getChatMessages(uuid, 2)); }); - test('resendMessage - не делает ничего если сообщение не найдено', () async { - const uuid = 'room-uuid'; - const messageUuid = 'non-existent-uuid'; - final initialMessages = ChatMessagesModel( - messagesList: [ - ChatMessageModel( - id: 1, - data: 'Test message', - mediaData: null, - authorId: 1, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: true, - roomId: 'chat-id', + test( + 'resendMessage - не делает ничего если сообщение не найдено', + () async { + const uuid = 'room-uuid'; + const messageUuid = 'non-existent-uuid'; + final initialMessages = ChatMessagesModel( + messagesList: [ + ChatMessageModel( + id: 1, + data: 'Test message', + mediaData: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: true, + roomId: 'chat-id', + ), + ], + meta: const ChatMetaModel(lastPage: 1), + ); + + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => initialMessages); + 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)); + + await chatRoomCubit.resendMessage( + uuid: messageUuid, + userSlug: 'user-1', + ); + await Future.delayed(const Duration(milliseconds: 100)); + + verifyNever( + mockChatRepository.sendMessage( + data: anyNamed('data'), + files: anyNamed('files'), + slug: anyNamed('slug'), ), - ], - meta: const ChatMetaModel(lastPage: 1), - ); + ); + }, + ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => initialMessages); - 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')); + test( + 'initChat - вызывает checkCards если consultantId == sender.id', + () 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, // совпадает с sender.id + plan: testPlan, + ); - await chatRoomCubit.initChat(uuid); - await Future.delayed(const Duration(milliseconds: 200)); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + 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(mockCardsRepository.getCards()).thenAnswer((_) async => []); - await chatRoomCubit.resendMessage(uuid: messageUuid, userSlug: 'user-1'); - await Future.delayed(const Duration(milliseconds: 100)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - verifyNever(mockChatRepository.sendMessage(data: anyNamed('data'), files: anyNamed('files'), slug: anyNamed('slug'))); - }); + verify(mockCardsRepository.getCards()).called(1); + }, + ); - test('initChat - вызывает checkCards если consultantId == sender.id', () 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, // совпадает с sender.id - plan: testPlan, - ); + test( + 'initChat - не вызывает checkCards если consultantId != sender.id', + () 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: 2, // не совпадает с sender.id (1) + plan: testPlan, + ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - 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(mockCardsRepository.getCards()).thenAnswer((_) async => []); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - verify(mockCardsRepository.getCards()).called(1); - }); - - test('initChat - не вызывает checkCards если consultantId != sender.id', () 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: 2, // не совпадает с sender.id (1) - plan: testPlan, - ); - - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - 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)); - - verifyNever(mockCardsRepository.getCards()); - }); + verifyNever(mockCardsRepository.getCards()); + }, + ); test('choseStartPlanDate - обрабатывает ошибку', () async { const uuid = 'room-uuid'; @@ -1294,10 +1897,20 @@ void main() { image: 'https://example.com/image.jpg', duration: 7, state: 'active', - type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + 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), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), totalLikes: 10, totalViews: 100, totalComments: 5, @@ -1316,12 +1929,25 @@ void main() { plan: testPlan, ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.choseStartPlanDate(id: planId, date: anyNamed('date'))).thenThrow(Exception('Error')); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => chatRoomWithPlan); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + when( + mockChatRepository.choseStartPlanDate( + id: planId, + date: anyNamed('date'), + ), + ).thenThrow(Exception('Error')); when(mockWsManager.client).thenReturn(null); - when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when( + mockWsManager.initPrivateDirectChatChannel(any), + ).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.directChatStream).thenReturn(null); when(mockWsManager.clientTypingStream).thenReturn(null); @@ -1329,21 +1955,31 @@ void main() { await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); - final result = await chatRoomCubit.choseStartPlanDate(DateTime(2024, 1, 15)); + final result = await chatRoomCubit.choseStartPlanDate( + DateTime(2024, 1, 15), + ); expect(result, false); }); test('checkCards - обрабатывает ошибку', () async { const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + 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.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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); @@ -1371,63 +2007,82 @@ void main() { await chatRoomCubit.resendMessage(uuid: uuid, userSlug: 'user-1'); - verifyNever(mockChatRepository.sendMessage(data: anyNamed('data'), files: anyNamed('files'), slug: anyNamed('slug'))); + verifyNever( + mockChatRepository.sendMessage( + data: anyNamed('data'), + files: anyNamed('files'), + slug: anyNamed('slug'), + ), + ); }); - test('loadNextPage - обновляет currentPage после успешной загрузки', () async { - const uuid = 'room-uuid'; - final initialMessages = ChatMessagesModel( - messagesList: [ - ChatMessageModel( - id: 1, - data: 'Initial message', - mediaData: null, - authorId: 1, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: true, - roomId: 'chat-id', - ), - ], - meta: const ChatMetaModel(lastPage: 2), - ); - final nextPageMessages = ChatMessagesModel( - messagesList: [ - ChatMessageModel( - id: 2, - data: 'Next page message', - mediaData: null, - authorId: 1, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: true, - roomId: 'chat-id', - ), - ], - meta: const ChatMetaModel(lastPage: 2), - ); + test( + 'loadNextPage - обновляет currentPage после успешной загрузки', + () async { + const uuid = 'room-uuid'; + final initialMessages = ChatMessagesModel( + messagesList: [ + ChatMessageModel( + id: 1, + data: 'Initial message', + mediaData: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: true, + roomId: 'chat-id', + ), + ], + meta: const ChatMetaModel(lastPage: 2), + ); + final nextPageMessages = ChatMessagesModel( + messagesList: [ + ChatMessageModel( + id: 2, + data: 'Next page message', + mediaData: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: true, + roomId: 'chat-id', + ), + ], + meta: const ChatMetaModel(lastPage: 2), + ); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => initialMessages); - 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.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => initialMessages); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - expect(chatRoomCubit.currentPage, 1); + expect(chatRoomCubit.currentPage, 1); - when(mockChatRepository.getChatMessages(uuid, 2)).thenAnswer((_) async => nextPageMessages); + when( + mockChatRepository.getChatMessages(uuid, 2), + ).thenAnswer((_) async => nextPageMessages); - await chatRoomCubit.loadNextPage(uuid); - await Future.delayed(const Duration(milliseconds: 200)); + await chatRoomCubit.loadNextPage(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - expect(chatRoomCubit.currentPage, 2); - }); + expect(chatRoomCubit.currentPage, 2); + }, + ); test('loadNextPage - не загружает если state не loaded', () async { const uuid = 'room-uuid'; @@ -1444,50 +2099,71 @@ 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')); + 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)); + 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', - ); + final incoming = ChatMessageModel( + id: 99, + data: 'Incoming socket message', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + ); - final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage(incoming); + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( + incoming, + ); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(shouldPlayIncomingSound, isTrue); - expect(state.chatMessages.first.id, 99); - expect(state.chatMessages.length, 2); - verify(mockChatRepository.emitNewMessageToChats(incoming)).called(1); - }); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(shouldPlayIncomingSound, isTrue); + expect(state.chatMessages.first.id, 99); + expect(state.chatMessages.length, 2); + verify(mockChatRepository.emitNewMessageToChats(incoming)).called(1); + }, + ); test('addIncomingMessage - не добавляет дубликат по id', () async { const uuid = 'room-uuid'; - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + 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.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.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); await chatRoomCubit.initChat(uuid); await Future.delayed(const Duration(milliseconds: 200)); @@ -1503,7 +2179,9 @@ void main() { roomId: 'chat-id', ); - final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage(incoming); + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( + incoming, + ); final state = chatRoomCubit.state as ChatRoomStateLoaded; expect(shouldPlayIncomingSound, isFalse); @@ -1512,88 +2190,115 @@ void main() { verifyNever(mockChatRepository.emitNewMessageToChats(any)); }); - test('addIncomingMessage - не добавляет дубликат по uuid (эхо оптимистичного сообщения)', () 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')); + test( + 'addIncomingMessage - не добавляет дубликат по uuid (эхо оптимистичного сообщения)', + () 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - chatRoomCubit.addIncomingMessage( - ChatMessageModel( - id: 501, - data: 'Echo with client uuid', - mediaData: null, - authorId: 2, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: false, - roomId: 'chat-id', - uuid: 'client-uuid-1', - ), - ); + chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 501, + data: 'Echo with client uuid', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + uuid: 'client-uuid-1', + ), + ); - final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( - ChatMessageModel( - id: 502, - data: 'Same client uuid, server id differs', - mediaData: null, - authorId: 2, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: false, - roomId: 'chat-id', - uuid: 'client-uuid-1', - ), - ); + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 502, + data: 'Same client uuid, server id differs', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + uuid: 'client-uuid-1', + ), + ); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(shouldPlayIncomingSound, isFalse); - expect(state.chatMessages.where((message) => message.uuid == 'client-uuid-1').length, 1); - }); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(shouldPlayIncomingSound, isFalse); + expect( + state.chatMessages + .where((message) => message.uuid == 'client-uuid-1') + .length, + 1, + ); + }, + ); - 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')); + 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)); + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); - final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( - ChatMessageModel( - id: 77, - data: 'Own echo', - mediaData: null, - authorId: 1, - updatedAt: DateTime.now(), - isRead: true, - isCurrentUserAuthor: false, - roomId: 'chat-id', - ), - ); + final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 77, + data: 'Own echo', + mediaData: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: true, + isCurrentUserAuthor: false, + roomId: 'chat-id', + ), + ); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(shouldPlayIncomingSound, isFalse); - expect(state.chatMessages.length, 2); - expect(state.chatMessages.any((message) => message.id == 77), isTrue); - // Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}). - verifyNever(mockChatRepository.emitNewMessageToChats(any)); - }); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(shouldPlayIncomingSound, isFalse); + expect(state.chatMessages.length, 2); + expect(state.chatMessages.any((message) => message.id == 77), isTrue); + // Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}). + verifyNever(mockChatRepository.emitNewMessageToChats(any)); + }, + ); test('addIncomingMessage - игнорирует событие, когда state не loaded', () { final shouldPlayIncomingSound = chatRoomCubit.addIncomingMessage( @@ -1614,193 +2319,262 @@ void main() { verifyNever(mockChatRepository.emitNewMessageToChats(any)); }); - 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; + 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); + 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)); + 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', + 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, + ); + }, + ); + + test( + 'refreshCurrentChat - подтягивает сообщение, добавленное в БД в обход сокета', + () async { + const uuid = 'room-uuid'; + final reconnectController = StreamController.broadcast(); + + when( + mockWsManager.reconnected, + ).thenAnswer((_) => reconnectController.stream); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); + 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: 100)); + + final missedMessage = ChatMessageModel( + id: 99, + data: 'Missed during outage', mediaData: null, authorId: 2, updatedAt: DateTime.now(), isRead: false, isCurrentUserAuthor: false, roomId: 'chat-id', - ), - ); + ); - planCompleter.complete(updatedPlan); - await updateFuture; + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer( + (_) async => ChatMessagesModel( + messagesList: [missedMessage, ...testChatMessages.messagesList], + meta: const ChatMetaModel(lastPage: 3), + ), + ); - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.chatMessages.any((message) => message.id == 55), isTrue); - expect(state.chatRoom.chosenPlan?.chosenPlanState, ChosenPlanStateEnum.statePaid); - }); + reconnectController.add(null); + await Future.delayed(const Duration(milliseconds: 100)); - test('markMessagesAsRead - не затирает сообщения, пришедшие во время запроса', () async { - const uuid = 'room-uuid'; - const chatId = 'chat-id'; - final markCompleter = Completer(); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.chatMessages.any((message) => message.id == 99), isTrue); + expect(state.chatMessages.any((message) => message.id == 1), isTrue); + expect(chatRoomCubit.lastPage, 3); - 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 reconnectController.close(); + }, + ); - await chatRoomCubit.initChat(uuid); - await Future.delayed(const Duration(milliseconds: 200)); + test( + 'refreshCurrentChat - идемпотентен: повторный реконнект не двоит сообщения', + () async { + const uuid = 'room-uuid'; + final reconnectController = StreamController.broadcast(); - 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, - ), - ); + when( + mockWsManager.reconnected, + ).thenAnswer((_) => reconnectController.stream); + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => throw Exception('No plan')); + 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); - markCompleter.complete(); - await markFuture; + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 100)); - 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); - }); + reconnectController.add(null); + reconnectController.add(null); + await Future.delayed(const Duration(milliseconds: 100)); - test('refreshCurrentChat - подтягивает сообщение, добавленное в БД в обход сокета', () async { - const uuid = 'room-uuid'; - final reconnectController = StreamController.broadcast(); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect( + state.chatMessages.where((message) => message.id == 1).length, + 1, + ); - when(mockWsManager.reconnected).thenAnswer((_) => reconnectController.stream); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); - 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: 100)); - - final missedMessage = ChatMessageModel( - id: 99, - data: 'Missed during outage', - mediaData: null, - authorId: 2, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: false, - roomId: 'chat-id', - ); - - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer( - (_) async => ChatMessagesModel( - messagesList: [missedMessage, ...testChatMessages.messagesList], - meta: const ChatMetaModel(lastPage: 3), - ), - ); - - reconnectController.add(null); - await Future.delayed(const Duration(milliseconds: 100)); - - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.chatMessages.any((message) => message.id == 99), isTrue); - expect(state.chatMessages.any((message) => message.id == 1), isTrue); - expect(chatRoomCubit.lastPage, 3); - - await reconnectController.close(); - }); - - test('refreshCurrentChat - идемпотентен: повторный реконнект не двоит сообщения', () async { - const uuid = 'room-uuid'; - final reconnectController = StreamController.broadcast(); - - when(mockWsManager.reconnected).thenAnswer((_) => reconnectController.stream); - when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); - when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); - when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); - 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: 100)); - - reconnectController.add(null); - reconnectController.add(null); - await Future.delayed(const Duration(milliseconds: 100)); - - final state = chatRoomCubit.state as ChatRoomStateLoaded; - expect(state.chatMessages.where((message) => message.id == 1).length, 1); - - await reconnectController.close(); - }); + await reconnectController.close(); + }, + ); }); } diff --git a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart index d017b976..c3031f4b 100644 --- a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart +++ b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart @@ -28,7 +28,9 @@ import 'chat_screen_loaded_smoke_test.mocks.dart'; void main() { group('ChatScreen loaded smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); await initializeDateFormatting('ru', null); await initializeDateFormatting('ru_RU', null); await initializeDateFormatting('RU', null); @@ -37,7 +39,11 @@ void main() { setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); sl.registerLazySingleton(() => _FakeWsManager()); }); @@ -47,7 +53,9 @@ void main() { '''; - final fakeBundle = _FakeAssetBundle({'assets/icons/error_icon.svg': fakeSvg}); + final fakeBundle = _FakeAssetBundle({ + 'assets/icons/error_icon.svg': fakeSvg, + }); Widget wrap(Widget child, ChatsCubit chatsCubit) { return DefaultAssetBundle( @@ -70,8 +78,18 @@ void main() { id: '1', chatRoomSettings: ChatRoomSettingsModel(name: 'room', state: 'active'), users: Users( - sender: ChatUserModel(id: 1, name: 'Me', slug: 'me', profilePhotoUrl: null), - receiver: ChatUserModel(id: 2, name: 'User Two', slug: 'user-two', profilePhotoUrl: null), + sender: ChatUserModel( + id: 1, + name: 'Me', + slug: 'me', + profilePhotoUrl: null, + ), + receiver: ChatUserModel( + id: 2, + name: 'User Two', + slug: 'user-two', + profilePhotoUrl: null, + ), ), chosenPlanId: null, chosenPlan: null, @@ -147,12 +165,19 @@ void main() { ); when(chatRoomCubit.state).thenReturn(state); - when(chatRoomCubit.stream).thenAnswer((_) => const Stream.empty()); + when( + chatRoomCubit.stream, + ).thenAnswer((_) => const Stream.empty()); when(chatRoomCubit.close()).thenAnswer((_) async => Future.value()); - when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId'))) - .thenAnswer((_) async => 'uuid'); + when( + chatRoomCubit.initChat( + any, + sendInitialMessage: anyNamed('sendInitialMessage'), + orderId: anyNamed('orderId'), + ), + ).thenAnswer((_) async => 'uuid'); when(chatRoomCubit.setTimer()).thenReturn(null); - + when(chatRoomCubit.refreshChat()).thenAnswer((_) async {}); sl.registerFactory(() => chatRoomCubit); @@ -160,11 +185,17 @@ void main() { when(chatsCubit.stream).thenAnswer((_) => const Stream.empty()); when(chatsCubit.close()).thenAnswer((_) async => Future.value()); - await tester.pumpWidget(wrap(const ChatScreen(uuid: 'uuid', sendInitialMessage: false), chatsCubit)); + await tester.pumpWidget( + wrap( + const ChatScreen(uuid: 'uuid', sendInitialMessage: false), + chatsCubit, + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(ChatScreen), findsOneWidget); + expect(find.byType(RefreshIndicator), findsOneWidget); expect(find.text('User Two'), findsOneWidget); expect(find.text('hello'), findsOneWidget); expect(find.text('hi'), findsOneWidget); diff --git a/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart b/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart index c8da17a8..d0b1a78c 100644 --- a/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart +++ b/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart @@ -82,12 +82,65 @@ void main() { cubit.setSelectedOptionIndex(0); cubit.setPrice(1500.0); expect(cubit.state.planData.options[0].price, 1500.0); - expect(cubit.state.validationState.optionsValidationState[0].priceHasError, false); + expect( + cubit.state.validationState.optionsValidationState[0].priceHasError, + false, + ); cubit.setPrice(1000.0); - expect(cubit.state.validationState.optionsValidationState[0].priceHasError, true); + expect( + cubit.state.validationState.optionsValidationState[0].priceHasError, + true, + ); }); + test( + 'option title, description, price and content stay independent per option', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionTitle('Economy'); + cubit.setPrice(1500.0); + cubit.setOptionDescription('Economy description'); + cubit.setOptionContent('Economy item', 0); + + cubit.setSelectedOptionIndex(1); + cubit.setOptionTitle('Standard'); + cubit.setPrice(2500.0); + cubit.setOptionDescription('Standard description'); + cubit.setOptionContent('Standard item', 0); + + cubit.setSelectedOptionIndex(2); + cubit.setOptionTitle('Comfort'); + cubit.setPrice(3500.0); + cubit.setOptionDescription('Comfort description'); + cubit.setOptionContent('Comfort item', 0); + + expect(cubit.state.planData.options[0].short, 'Economy'); + expect(cubit.state.planData.options[0].price, 1500.0); + expect( + cubit.state.planData.options[0].description, + 'Economy description', + ); + expect(cubit.state.planData.options[0].items, ['Economy item']); + + expect(cubit.state.planData.options[1].short, 'Standard'); + expect(cubit.state.planData.options[1].price, 2500.0); + expect( + cubit.state.planData.options[1].description, + 'Standard description', + ); + expect(cubit.state.planData.options[1].items, ['Standard item']); + + expect(cubit.state.planData.options[2].short, 'Comfort'); + expect(cubit.state.planData.options[2].price, 3500.0); + expect( + cubit.state.planData.options[2].description, + 'Comfort description', + ); + expect(cubit.state.planData.options[2].items, ['Comfort item']); + }, + ); + test('setOptionDescription updates description and validates', () { cubit.setSelectedOptionIndex(0); cubit.setOptionDescription('Test description'); @@ -129,11 +182,17 @@ void main() { test('validateSelectedOptionItems validates items', () { cubit.setSelectedOptionIndex(0); expect(cubit.validateSelectedOptionItems(), false); - expect(cubit.state.validationState.optionsValidationState[0].isItemsEmpty, true); + expect( + cubit.state.validationState.optionsValidationState[0].isItemsEmpty, + true, + ); cubit.setOptionContent('Valid item', 0); expect(cubit.validateSelectedOptionItems(), true); - expect(cubit.state.validationState.optionsValidationState[0].isItemsEmpty, false); + expect( + cubit.state.validationState.optionsValidationState[0].isItemsEmpty, + false, + ); cubit.setOptionContent('AB', 1); expect(cubit.validateSelectedOptionItems(), false); @@ -225,18 +284,27 @@ void main() { expect(cubit.state.validationState.imageHasError, false); }); - test('validateSelectedOptionPrice returns false for price less than 1200', () { - cubit.setSelectedOptionIndex(0); - cubit.setPrice(1000.0); - expect(cubit.validateSelectedOptionPrice(), false); - expect(cubit.state.validationState.optionsValidationState[0].priceHasError, true); - }); + test( + 'validateSelectedOptionPrice returns false for price less than 1200', + () { + cubit.setSelectedOptionIndex(0); + cubit.setPrice(1000.0); + expect(cubit.validateSelectedOptionPrice(), false); + expect( + cubit.state.validationState.optionsValidationState[0].priceHasError, + true, + ); + }, + ); test('validateSelectedOptionPrice returns true for price >= 1200', () { cubit.setSelectedOptionIndex(0); cubit.setPrice(1200.0); expect(cubit.validateSelectedOptionPrice(), true); - expect(cubit.state.validationState.optionsValidationState[0].priceHasError, false); + expect( + cubit.state.validationState.optionsValidationState[0].priceHasError, + false, + ); }); test('setImage updates image and validates', () { @@ -260,28 +328,37 @@ void main() { expect(cubit.state.planData.options[0].items[0], 'Item 1'); }); - test('validatePriceOfAllFilledOptions returns false when some prices are invalid', () { - cubit.setSelectedOptionIndex(0); - cubit.setPrice(1500.0); - cubit.setSelectedOptionIndex(1); - cubit.setPrice(1000.0); // Invalid - cubit.setSelectedOptionIndex(2); - cubit.setPrice(1500.0); - expect(cubit.validatePriceOfAllFilledOptions(), false); - }); + test( + 'validatePriceOfAllFilledOptions returns false when some prices are invalid', + () { + cubit.setSelectedOptionIndex(0); + cubit.setPrice(1500.0); + cubit.setSelectedOptionIndex(1); + cubit.setPrice(1000.0); // Invalid + cubit.setSelectedOptionIndex(2); + cubit.setPrice(1500.0); + expect(cubit.validatePriceOfAllFilledOptions(), false); + }, + ); test('validateSelectedOptionTitle returns false for short title', () { cubit.setSelectedOptionIndex(0); cubit.setOptionTitle('AB'); expect(cubit.validateSelectedOptionTitle(), false); - expect(cubit.state.validationState.optionsValidationState[0].titleHasError, true); + expect( + cubit.state.validationState.optionsValidationState[0].titleHasError, + true, + ); }); test('validateSelectedOptionTitle returns true for valid title', () { cubit.setSelectedOptionIndex(0); cubit.setOptionTitle('Valid Title'); expect(cubit.validateSelectedOptionTitle(), true); - expect(cubit.state.validationState.optionsValidationState[0].titleHasError, false); + expect( + cubit.state.validationState.optionsValidationState[0].titleHasError, + false, + ); }); test('validateTitlesOfAllFilledOptions validates all options', () { @@ -294,37 +371,63 @@ void main() { expect(cubit.validateTitlesOfAllFilledOptions(), true); }); - test('validateTitlesOfAllFilledOptions returns false when some titles are invalid', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionTitle('Valid Title'); - cubit.setSelectedOptionIndex(1); - cubit.setOptionTitle('AB'); // Invalid - cubit.setSelectedOptionIndex(2); - cubit.setOptionTitle('Valid Title'); - expect(cubit.validateTitlesOfAllFilledOptions(), false); - }); + test( + 'validateTitlesOfAllFilledOptions returns false when some titles are invalid', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionTitle('Valid Title'); + cubit.setSelectedOptionIndex(1); + cubit.setOptionTitle('AB'); // Invalid + cubit.setSelectedOptionIndex(2); + cubit.setOptionTitle('Valid Title'); + expect(cubit.validateTitlesOfAllFilledOptions(), false); + }, + ); - test('validateTitlesOfAllFilledOptions returns early when selectedOptionIndex is null', () { - cubit.setSelectedOptionIndex(null); - cubit.setOptionTitle('Title 1'); - final result = cubit.validateTitlesOfAllFilledOptions(); - // Should only validate first option when selectedOptionIndex is null - expect(result, isA()); - }); + test( + 'validateTitlesOfAllFilledOptions returns early when selectedOptionIndex is null', + () { + cubit.setSelectedOptionIndex(null); + cubit.setOptionTitle('Title 1'); + final result = cubit.validateTitlesOfAllFilledOptions(); + // Should only validate first option when selectedOptionIndex is null + expect(result, isA()); + }, + ); - test('validateSelectedOptionDescription returns false for short description', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionDescription('AB'); - expect(cubit.validateSelectedOptionDescription(), false); - expect(cubit.state.validationState.optionsValidationState[0].descriptionHasError, true); - }); + test( + 'validateSelectedOptionDescription returns false for short description', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionDescription('AB'); + expect(cubit.validateSelectedOptionDescription(), false); + expect( + cubit + .state + .validationState + .optionsValidationState[0] + .descriptionHasError, + true, + ); + }, + ); - test('validateSelectedOptionDescription returns true for valid description', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionDescription('Valid description'); - expect(cubit.validateSelectedOptionDescription(), true); - expect(cubit.state.validationState.optionsValidationState[0].descriptionHasError, false); - }); + test( + 'validateSelectedOptionDescription returns true for valid description', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionDescription('Valid description'); + expect(cubit.validateSelectedOptionDescription(), true); + expect( + cubit + .state + .validationState + .optionsValidationState[0] + .descriptionHasError, + false, + ); + }, + ); test('validateDescriptionsOfAllOptions validates all options', () { cubit.setSelectedOptionIndex(0); @@ -336,23 +439,29 @@ void main() { expect(cubit.validateDescriptionsOfAllOptions(), true); }); - test('validateDescriptionsOfAllOptions returns false when some descriptions are invalid', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionDescription('Valid description'); - cubit.setSelectedOptionIndex(1); - cubit.setOptionDescription('AB'); // Invalid - cubit.setSelectedOptionIndex(2); - cubit.setOptionDescription('Valid description'); - expect(cubit.validateDescriptionsOfAllOptions(), false); - }); + test( + 'validateDescriptionsOfAllOptions returns false when some descriptions are invalid', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionDescription('Valid description'); + cubit.setSelectedOptionIndex(1); + cubit.setOptionDescription('AB'); // Invalid + cubit.setSelectedOptionIndex(2); + cubit.setOptionDescription('Valid description'); + expect(cubit.validateDescriptionsOfAllOptions(), false); + }, + ); - test('validateDescriptionsOfAllOptions returns early when selectedOptionIndex is null', () { - cubit.setSelectedOptionIndex(null); - cubit.setOptionDescription('Description 1'); - final result = cubit.validateDescriptionsOfAllOptions(); - // Should only validate first option when selectedOptionIndex is null - expect(result, isA()); - }); + test( + 'validateDescriptionsOfAllOptions returns early when selectedOptionIndex is null', + () { + cubit.setSelectedOptionIndex(null); + cubit.setOptionDescription('Description 1'); + final result = cubit.validateDescriptionsOfAllOptions(); + // Should only validate first option when selectedOptionIndex is null + expect(result, isA()); + }, + ); test('validateItemsOfAllFilledOptions validates all options', () { cubit.setSelectedOptionIndex(0); @@ -364,29 +473,41 @@ void main() { expect(cubit.validateItemsOfAllFilledOptions(), true); }); - test('validateItemsOfAllFilledOptions returns false when some items are invalid', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionContent('Valid item', 0); - cubit.setSelectedOptionIndex(1); - cubit.setOptionContent('AB', 0); // Invalid - cubit.setSelectedOptionIndex(2); - cubit.setOptionContent('Valid item', 0); - expect(cubit.validateItemsOfAllFilledOptions(), false); - }); + test( + 'validateItemsOfAllFilledOptions returns false when some items are invalid', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionContent('Valid item', 0); + cubit.setSelectedOptionIndex(1); + cubit.setOptionContent('AB', 0); // Invalid + cubit.setSelectedOptionIndex(2); + cubit.setOptionContent('Valid item', 0); + expect(cubit.validateItemsOfAllFilledOptions(), false); + }, + ); - test('validateItemsOfAllFilledOptions returns false when items are empty', () { - cubit.setSelectedOptionIndex(0); - expect(cubit.validateItemsOfAllFilledOptions(), false); - expect(cubit.state.validationState.optionsValidationState[0].isItemsEmpty, true); - }); + test( + 'validateItemsOfAllFilledOptions returns false when items are empty', + () { + cubit.setSelectedOptionIndex(0); + expect(cubit.validateItemsOfAllFilledOptions(), false); + expect( + cubit.state.validationState.optionsValidationState[0].isItemsEmpty, + true, + ); + }, + ); - test('validateItemsOfAllFilledOptions returns early when selectedOptionIndex is null', () { - cubit.setSelectedOptionIndex(null); - cubit.setOptionContent('Item 1', 0); - final result = cubit.validateItemsOfAllFilledOptions(); - // Should only validate first option when selectedOptionIndex is null - expect(result, isA()); - }); + test( + 'validateItemsOfAllFilledOptions returns early when selectedOptionIndex is null', + () { + cubit.setSelectedOptionIndex(null); + cubit.setOptionContent('Item 1', 0); + final result = cubit.validateItemsOfAllFilledOptions(); + // Should only validate first option when selectedOptionIndex is null + expect(result, isA()); + }, + ); test('validateAllFields returns false when name is invalid', () { cubit.setName('AB'); @@ -456,20 +577,20 @@ void main() { cubit.setPlanType([true, false, false, false]); final image = XFile('test_image.jpg'); cubit.setImage(image); - + // Заполняем все три опции, так как validateItemsOfAllFilledOptions проверяет все cubit.setSelectedOptionIndex(0); cubit.setOptionTitle('Valid Title 1'); cubit.setPrice(1500.0); cubit.setOptionDescription('Valid description 1'); cubit.setOptionContent('Valid item 1', 0); - + cubit.setSelectedOptionIndex(1); cubit.setOptionTitle('Valid Title 2'); cubit.setPrice(2000.0); cubit.setOptionDescription('Valid description 2'); cubit.setOptionContent('Valid item 2', 0); - + cubit.setSelectedOptionIndex(2); cubit.setOptionTitle('Valid Title 3'); cubit.setPrice(2500.0); @@ -527,21 +648,24 @@ void main() { expect(cubit.validateAllFields(), false); }); - test('validateAllFields returns false when option description is invalid', () { - cubit.setName('Valid Name'); - cubit.setDetails('Valid description'); - cubit.setDuration(0); - cubit.setPlanType([true, false, false, false]); - final image = XFile('test_image.jpg'); - cubit.setImage(image); - cubit.setSelectedOptionIndex(0); - cubit.setOptionTitle('Valid Title'); - cubit.setPrice(1500.0); - cubit.setOptionDescription('AB'); // Invalid - cubit.setOptionContent('Valid item', 0); + test( + 'validateAllFields returns false when option description is invalid', + () { + cubit.setName('Valid Name'); + cubit.setDetails('Valid description'); + cubit.setDuration(0); + cubit.setPlanType([true, false, false, false]); + final image = XFile('test_image.jpg'); + cubit.setImage(image); + cubit.setSelectedOptionIndex(0); + cubit.setOptionTitle('Valid Title'); + cubit.setPrice(1500.0); + cubit.setOptionDescription('AB'); // Invalid + cubit.setOptionContent('Valid item', 0); - expect(cubit.validateAllFields(), false); - }); + expect(cubit.validateAllFields(), false); + }, + ); test('validateAllFields returns false when option items are invalid', () { cubit.setName('Valid Name'); @@ -611,115 +735,143 @@ void main() { expect(cubit.state.planData.options[0].items.length, 1); }); - test('setOptionContent - добавляет новый элемент когда индекс больше длины', () { - cubit.setSelectedOptionIndex(0); - expect(cubit.state.planData.options[0].items.length, 0); + test( + 'setOptionContent - добавляет новый элемент когда индекс больше длины', + () { + cubit.setSelectedOptionIndex(0); + expect(cubit.state.planData.options[0].items.length, 0); - cubit.setOptionContent('Item 1', 0); - expect(cubit.state.planData.options[0].items.length, 1); + cubit.setOptionContent('Item 1', 0); + expect(cubit.state.planData.options[0].items.length, 1); - cubit.setOptionContent('Item 2', 1); - expect(cubit.state.planData.options[0].items.length, 2); - }); + cubit.setOptionContent('Item 2', 1); + expect(cubit.state.planData.options[0].items.length, 2); + }, + ); - test('validateTitlesOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все опции валидны', () { - cubit.setSelectedOptionIndex(null); - cubit.setOptionTitle('Valid Title 1'); - cubit.setSelectedOptionIndex(0); - cubit.setOptionTitle('Valid Title 1'); - cubit.setSelectedOptionIndex(1); - cubit.setOptionTitle('Valid Title 2'); - cubit.setSelectedOptionIndex(2); - cubit.setOptionTitle('Valid Title 3'); + test( + 'validateTitlesOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все опции валидны', + () { + cubit.setSelectedOptionIndex(null); + cubit.setOptionTitle('Valid Title 1'); + cubit.setSelectedOptionIndex(0); + cubit.setOptionTitle('Valid Title 1'); + cubit.setSelectedOptionIndex(1); + cubit.setOptionTitle('Valid Title 2'); + cubit.setSelectedOptionIndex(2); + cubit.setOptionTitle('Valid Title 3'); - cubit.setSelectedOptionIndex(null); - final result = cubit.validateTitlesOfAllFilledOptions(); - expect(result, true); - }); + cubit.setSelectedOptionIndex(null); + final result = cubit.validateTitlesOfAllFilledOptions(); + expect(result, true); + }, + ); - test('validateTitlesOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionTitle('AB'); // невалидное название - cubit.setSelectedOptionIndex(null); + test( + 'validateTitlesOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionTitle('AB'); // невалидное название + cubit.setSelectedOptionIndex(null); - final result = cubit.validateTitlesOfAllFilledOptions(); - expect(result, false); - }); + final result = cubit.validateTitlesOfAllFilledOptions(); + expect(result, false); + }, + ); - test('validateDescriptionsOfAllOptions - возвращает true когда selectedOptionIndex == null и все описания валидны', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionDescription('Valid Description 1'); - cubit.setSelectedOptionIndex(1); - cubit.setOptionDescription('Valid Description 2'); - cubit.setSelectedOptionIndex(2); - cubit.setOptionDescription('Valid Description 3'); + test( + 'validateDescriptionsOfAllOptions - возвращает true когда selectedOptionIndex == null и все описания валидны', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionDescription('Valid Description 1'); + cubit.setSelectedOptionIndex(1); + cubit.setOptionDescription('Valid Description 2'); + cubit.setSelectedOptionIndex(2); + cubit.setOptionDescription('Valid Description 3'); - cubit.setSelectedOptionIndex(null); - final result = cubit.validateDescriptionsOfAllOptions(); - expect(result, true); - }); + cubit.setSelectedOptionIndex(null); + final result = cubit.validateDescriptionsOfAllOptions(); + expect(result, true); + }, + ); - test('validateDescriptionsOfAllOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionDescription('AB'); // невалидное описание - cubit.setSelectedOptionIndex(null); + test( + 'validateDescriptionsOfAllOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionDescription('AB'); // невалидное описание + cubit.setSelectedOptionIndex(null); - final result = cubit.validateDescriptionsOfAllOptions(); - expect(result, false); - }); + final result = cubit.validateDescriptionsOfAllOptions(); + expect(result, false); + }, + ); - test('validateItemsOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все items валидны', () { - cubit.setSelectedOptionIndex(0); - cubit.setOptionContent('Valid Item 1', 0); - cubit.setSelectedOptionIndex(1); - cubit.setOptionContent('Valid Item 2', 0); - cubit.setSelectedOptionIndex(2); - cubit.setOptionContent('Valid Item 3', 0); + test( + 'validateItemsOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все items валидны', + () { + cubit.setSelectedOptionIndex(0); + cubit.setOptionContent('Valid Item 1', 0); + cubit.setSelectedOptionIndex(1); + cubit.setOptionContent('Valid Item 2', 0); + cubit.setSelectedOptionIndex(2); + cubit.setOptionContent('Valid Item 3', 0); - cubit.setSelectedOptionIndex(null); - final result = cubit.validateItemsOfAllFilledOptions(); - expect(result, true); - }); + cubit.setSelectedOptionIndex(null); + final result = cubit.validateItemsOfAllFilledOptions(); + expect(result, true); + }, + ); - test('validateItemsOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 имеет пустые items', () { - cubit.setSelectedOptionIndex(0); - // items пустые - cubit.setSelectedOptionIndex(null); + test( + 'validateItemsOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 имеет пустые items', + () { + cubit.setSelectedOptionIndex(0); + // items пустые + cubit.setSelectedOptionIndex(null); - final result = cubit.validateItemsOfAllFilledOptions(); - expect(result, false); - }); + final result = cubit.validateItemsOfAllFilledOptions(); + expect(result, false); + }, + ); - test('validatePriceOfAllFilledOptions - возвращает true когда все цены валидны', () { - cubit.setSelectedOptionIndex(0); - cubit.setPrice(1500.0); - cubit.setSelectedOptionIndex(1); - cubit.setPrice(2000.0); - cubit.setSelectedOptionIndex(2); - cubit.setPrice(2500.0); + test( + 'validatePriceOfAllFilledOptions - возвращает true когда все цены валидны', + () { + cubit.setSelectedOptionIndex(0); + cubit.setPrice(1500.0); + cubit.setSelectedOptionIndex(1); + cubit.setPrice(2000.0); + cubit.setSelectedOptionIndex(2); + cubit.setPrice(2500.0); - final result = cubit.validatePriceOfAllFilledOptions(); - expect(result, true); - }); + final result = cubit.validatePriceOfAllFilledOptions(); + expect(result, true); + }, + ); - test('validatePriceOfAllFilledOptions - возвращает false когда опция 1 имеет невалидную цену', () { - cubit.setSelectedOptionIndex(0); - cubit.setPrice(1500.0); - cubit.setSelectedOptionIndex(1); - cubit.setPrice(1000.0); // невалидная цена - cubit.setSelectedOptionIndex(2); - cubit.setPrice(2500.0); - - final result = cubit.validatePriceOfAllFilledOptions(); - expect(result, false); - }); - - test('setPlanType - не валидирует когда type.any((elem) => elem) == false', () { - cubit.setPlanType([false, false, false, false]); - expect(cubit.state.planData.type, [false, false, false, false]); - // Валидация не должна вызываться, так как все элементы false - }); + test( + 'validatePriceOfAllFilledOptions - возвращает false когда опция 1 имеет невалидную цену', + () { + cubit.setSelectedOptionIndex(0); + cubit.setPrice(1500.0); + cubit.setSelectedOptionIndex(1); + cubit.setPrice(1000.0); // невалидная цена + cubit.setSelectedOptionIndex(2); + cubit.setPrice(2500.0); + final result = cubit.validatePriceOfAllFilledOptions(); + expect(result, false); + }, + ); + test( + 'setPlanType - не валидирует когда type.any((elem) => elem) == false', + () { + cubit.setPlanType([false, false, false, false]); + expect(cubit.state.planData.type, [false, false, false, false]); + // Валидация не должна вызываться, так как все элементы false + }, + ); }); } diff --git a/test/features/create_plan/presentation/widgets/option_widget_test.dart b/test/features/create_plan/presentation/widgets/option_widget_test.dart index f0a9fa94..782738a9 100644 --- a/test/features/create_plan/presentation/widgets/option_widget_test.dart +++ b/test/features/create_plan/presentation/widgets/option_widget_test.dart @@ -3,8 +3,9 @@ import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; -import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart'; +import 'package:eatvio_app/features/create_plan/presentation/widgets/select_exact_option_widget.dart'; +import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -12,12 +13,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'option_widget_test.mocks.dart'; - -@GenerateNiceMocks([MockSpec()]) void main() { group('OptionWidget smoke', () { setUpAll(() async { @@ -47,18 +43,8 @@ void main() { tester.view.physicalSize = const Size(800, 1200); tester.view.devicePixelRatio = 1.0; - final cubit = MockCreatePlanCubit(); - final state = CreatePlanState.initial(); - - when(cubit.state).thenReturn(state); - when(cubit.stream).thenAnswer((_) => Stream.value(state)); - when(cubit.close()).thenAnswer((_) async => Future.value()); - when(cubit.setOptionTitle(any)).thenReturn(null); - when(cubit.setOptionDescription(any)).thenReturn(null); - when(cubit.setPrice(any)).thenReturn(null); - when(cubit.setOptionContent(any, any)).thenReturn(null); - when(cubit.removeOptionContentItem(any)).thenReturn(null); - when(cubit.togglePersonalizedDietOptionState()).thenReturn(null); + final cubit = CreatePlanCubit(createPlanRepository: _FakeCreatePlanRepository()); + addTearDown(cubit.close); await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const OptionWidget()))); await tester.pump(); @@ -66,5 +52,61 @@ void main() { expect(find.byType(OptionWidget), findsOneWidget); }); + + testWidgets('keeps title and price independent when switching options', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + + final cubit = CreatePlanCubit(createPlanRepository: _FakeCreatePlanRepository()); + cubit.setSelectedOptionIndex(0); + addTearDown(cubit.close); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap( + const Column( + children: [ + SelectExactOptionWidget(), + Expanded(child: SingleChildScrollView(child: OptionWidget())), + ], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final firstFields = find.byType(TextFormField); + await tester.enterText(firstFields.at(0), 'Economy title'); + await tester.enterText(firstFields.at(3), '1500'); + await tester.pump(); + + await tester.tap(find.text('2')); + await tester.pump(); + await tester.pump(); + + expect(cubit.state.selectedOptionIndex, 1); + + final secondFields = find.byType(TextFormField); + await tester.enterText(secondFields.at(0), 'Standard title'); + await tester.enterText(secondFields.at(3), '2500'); + await tester.pump(); + + expect(cubit.state.planData.options[0].short, 'Economy title'); + expect(cubit.state.planData.options[1].short, 'Standard title'); + expect(cubit.state.planData.options[0].price, 1500.0); + expect(cubit.state.planData.options[1].price, 2500.0); + + await tester.tap(find.text('1')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Economy title'), findsOneWidget); + expect(cubit.state.planData.options[1].short, 'Standard title'); + expect(cubit.state.planData.options[1].price, 2500.0); + }); }); } + +class _FakeCreatePlanRepository extends Fake implements CreatePlanRepository {} diff --git a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart new file mode 100644 index 00000000..45098fcf --- /dev/null +++ b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart @@ -0,0 +1,44 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('VideoCallAudioSessionCoordinator iOS', () { + test('start and stop go through the WebRTC audio session', () async { + final calls = []; + final coordinator = VideoCallAudioSessionCoordinator( + isIos: true, + iosCallAudioSession: (method) async { + calls.add(method); + }, + ); + + expect(coordinator.isActive, isFalse); + + await coordinator.start(); + expect(coordinator.isActive, isTrue); + + final resumed = await coordinator.reactivate(); + expect(resumed, isTrue); + + await coordinator.stop(); + expect(coordinator.isActive, isFalse); + expect(calls, ['activate', 'activate', 'deactivate']); + }); + + test('stop clears the active session when deactivate fails', () async { + final coordinator = VideoCallAudioSessionCoordinator( + isIos: true, + iosCallAudioSession: (method) async { + if (method == 'deactivate') { + throw StateError('audio session busy'); + } + }, + ); + + await coordinator.start(); + await coordinator.stop(); + + expect(coordinator.isActive, isFalse); + }); + }); +} diff --git a/test/features/welcome/presentation/pages/welcome_screen_test.dart b/test/features/welcome/presentation/pages/welcome_screen_test.dart index 961b712c..406d7c2b 100644 --- a/test/features/welcome/presentation/pages/welcome_screen_test.dart +++ b/test/features/welcome/presentation/pages/welcome_screen_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -18,28 +19,48 @@ import 'welcome_screen_test.mocks.dart'; void main() { group('WelcomeScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { SharedPreferences.setMockInitialValues({}); await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); tearDown(() async { await sl.reset(); }); - Widget wrap(Widget child) { + Widget wrap(Widget child, {double textScale = 1}) { return MaterialApp.router( theme: ThemeHelper().themeData(), + builder: (context, appChild) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: appChild ?? const SizedBox.shrink(), + ); + }, routerConfig: GoRouter( initialLocation: '/', routes: [ GoRoute(path: '/', builder: (context, state) => child), - GoRoute(path: '/feed-screen', builder: (context, state) => const SizedBox.shrink()), - GoRoute(path: '/auth-screen', builder: (context, state) => const SizedBox.shrink()), + GoRoute( + path: '/feed-screen', + builder: (context, state) => const SizedBox.shrink(), + ), + GoRoute( + path: '/auth-screen', + builder: (context, state) => const SizedBox.shrink(), + ), ], ), ); @@ -48,7 +69,9 @@ void main() { testWidgets('renders welcome screen', (tester) async { final prefs = MockPrefs(); when(prefs.getBool(any)).thenReturn(false); - when(prefs.setBool(any, any)).thenAnswer((_) async => Future.value()); + when( + prefs.setBool(any, any), + ).thenAnswer((_) async => Future.value()); sl.registerLazySingleton(() => prefs); @@ -59,5 +82,35 @@ void main() { expect(find.text('Добро пожаловать!'), findsOneWidget); expect(find.text('Начать пользоваться'), findsOneWidget); }); + + testWidgets('start button stays tappable when system text is enlarged', ( + tester, + ) async { + final prefs = MockPrefs(); + when(prefs.getBool(any)).thenReturn(false); + when( + prefs.setBool(any, any), + ).thenAnswer((_) async => Future.value()); + sl.registerLazySingleton(() => prefs); + + tester.view.physicalSize = const Size(390, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(wrap(const WelcomeScreen(), textScale: 2.4)); + await tester.pump(); + + final button = find.byKey(AppKeys.welcomeScreenButton); + await tester.ensureVisible(button); + await tester.pump(); + + expect(tester.takeException(), isNull); + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(find.text('Начать пользоваться'), findsNothing); + verify(prefs.setBool(any, true)).called(1); + }); }); }