From bd1155233f2bc6e127922a9d1fabd9c2f302063a Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Tue, 3 Nov 2026 14:45:44 +0300 Subject: [PATCH] feat(chat): add consultation progress and personal diet status related to #1115 --- lib/core/routing/other_routes.dart | 13 +- lib/core/websockets/ws_manager.dart | 9 + .../bloc/chat_room_cubit/chat_room_cubit.dart | 36 ++- .../chat/presentation/pages/chat_screen.dart | 149 +++------- .../utils/chat_banner_resolver.dart | 52 ++++ .../widgets/chat_consultation_header.dart | 112 +++++++ .../widgets/notification_banner.dart | 31 +- .../models/consultation_model.dart | 7 +- .../models/consultation_progress.dart | 31 ++ .../widgets/consultation_card_widget.dart | 19 ++ .../widgets/consultation_progress_plank.dart | 111 +++++++ .../widgets/personal_diet_status_chip.dart | 57 ++++ .../utils/consultation_progress_mapper.dart | 114 +++++++ .../presentation/cubit/my_diets_cubit.dart | 5 + .../presentation/my_diets_screen.dart | 11 +- .../cubit/personal_diet_suggestion_cubit.dart | 38 ++- .../presentation/consultation_widget.dart | 9 +- .../personal_diet_suggestion_repository.dart | 22 +- .../personal_diet_suggestion_service.dart | 62 ++-- .../plans/models/chosen_plan_model.dart | 15 + lib/generated/intl/messages_en.dart | 46 ++- lib/generated/intl/messages_ru.dart | 35 +++ lib/generated/l10n.dart | 60 ++++ lib/l10n/intl_en.arb | 13 +- lib/l10n/intl_ru.arb | 23 +- .../bloc/chat_room_cubit_test.dart | 219 ++++++++++++++ .../utils/chat_banner_resolver_test.dart | 232 +++++++++++++++ .../chat_consultation_header_test.dart | 82 +++++ .../consultation_progress_plank_test.dart | 71 +++++ .../personal_diet_status_chip_test.dart | 88 ++++++ .../consultation_progress_mapper_test.dart | 279 ++++++++++++++++++ .../personal_diet_suggestion_cubit_test.dart | 4 + .../consultation_widget_test.dart | 52 +++- ...sonal_diet_suggestion_repository_test.dart | 24 +- ...personal_diet_suggestion_service_test.dart | 133 ++++----- 35 files changed, 1999 insertions(+), 265 deletions(-) create mode 100644 lib/features/chat/presentation/utils/chat_banner_resolver.dart create mode 100644 lib/features/chat/presentation/widgets/chat_consultation_header.dart create mode 100644 lib/features/consultations/models/consultation_progress.dart create mode 100644 lib/features/consultations/presentation/widgets/consultation_progress_plank.dart create mode 100644 lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart create mode 100644 lib/features/consultations/utils/consultation_progress_mapper.dart create mode 100644 test/features/chat/presentation/utils/chat_banner_resolver_test.dart create mode 100644 test/features/chat/presentation/widgets/chat_consultation_header_test.dart create mode 100644 test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart create mode 100644 test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart create mode 100644 test/features/consultations/utils/consultation_progress_mapper_test.dart diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 96f5f2cb..266ecece 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -192,7 +192,18 @@ class OtherRoutes { return CreatePlanScreen(plan: plan); }, ), - GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()), + GoRoute( + path: MyDietsScreen.path, + name: MyDietsScreen.name, + builder: (context, state) { + final initialTabIndex = int.tryParse( + state.uri.queryParameters['initialTabIndex'] ?? '', + ); + return MyDietsScreen( + initialTabIndex: initialTabIndex ?? 0, + ); + }, + ), GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()), GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()), diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 3ba31b8a..893ca177 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -48,6 +48,7 @@ class WsManager { Stream? _directChatStream; Stream? _clientTypingStream; + Stream? _chosenPlanStateChangedStream; Stream? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); @@ -74,6 +75,9 @@ class WsManager { Stream? get clientTypingStream => _clientTypingStream; + Stream? get chosenPlanStateChangedStream => + _chosenPlanStateChangedStream; + /// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы. /// /// Подписчики используют его, чтобы догрузить пропущенные за время обрыва @@ -222,6 +226,9 @@ class WsManager { 'App\\Events\\ChatMessageUpdated', ); _clientTypingStream = _privateDirectChatChannel?.bind('client-typing'); + _chosenPlanStateChangedStream = _privateDirectChatChannel?.bind( + 'chosen-plan-state-changed', + ); } Future initPresenceDirectChatChannel(String chatId) async { @@ -367,6 +374,7 @@ class WsManager { _privateDirectChatChannel?.unsubscribe(); _directChatStream = null; _clientTypingStream = null; + _chosenPlanStateChangedStream = null; } void unsubscribePresenceDirectChatChannel() { @@ -401,6 +409,7 @@ class WsManager { _privateNotificationChannel = null; _directChatStream = null; _clientTypingStream = null; + _chosenPlanStateChangedStream = null; } } diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index 27ffb620..e0cb7d6a 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 @@ -31,7 +31,9 @@ class ChatRoomCubit extends Cubit { StreamSubscription? typingSubscription; StreamSubscription? messagesSubscription; + StreamSubscription? _chosenPlanStateSubscription; StreamSubscription? _reconnectSubscription; + bool _isUpdatingChosenPlan = false; int currentPage = 1; late int lastPage; bool isLoading = false; @@ -171,6 +173,7 @@ class ChatRoomCubit extends Cubit { log(e.toString()); typingSubscription?.cancel(); messagesSubscription?.cancel(); + _chosenPlanStateSubscription?.cancel(); _reconnectSubscription?.cancel(); _reconnectSubscription = null; wsManager.unsubscribeDirectChatChannel(); @@ -260,19 +263,30 @@ class ChatRoomCubit extends Cubit { } Future updateChosenPlanState() async { + if (_isUpdatingChosenPlan) return; try { + _isUpdatingChosenPlan = true; final currentState = state; if (currentState is! ChatRoomStateLoaded) { return; } - final chosenPlanId = currentState.chatRoom.chosenPlanId; + final chosenPlanId = + currentState.chatRoom.chosenPlanId ?? + currentState.chatRoom.chosenPlan?.id; if (chosenPlanId == null) { return; } final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId); - _emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan)); + if (isClosed) return; + _emitLoaded( + (latest) => latest.copyWith( + chatRoom: latest.chatRoom.copyWith(chosenPlan: chosenPlan), + ), + ); } catch (e, st) { await reportFrom(e, st, this); + } finally { + _isUpdatingChosenPlan = false; } } @@ -338,8 +352,10 @@ class ChatRoomCubit extends Cubit { if (client == null) return; await messagesSubscription?.cancel(); await typingSubscription?.cancel(); + await _chosenPlanStateSubscription?.cancel(); messagesSubscription = null; typingSubscription = null; + _chosenPlanStateSubscription = null; await wsManager.initPrivateDirectChatChannel(chatId); wsManager.subscribeDirectChatChannel(); messagesSubscription = wsManager.directChatStream?.listen( @@ -361,6 +377,20 @@ class ChatRoomCubit extends Cubit { ), ); }); + _chosenPlanStateSubscription = wsManager + .chosenPlanStateChangedStream + ?.listen( + (_) { + EasyDebounce.debounce( + 'chosen-plan-state-changed', + const Duration(milliseconds: 250), + () => unawaited(updateChosenPlanState()), + ); + }, + onError: (Object e, StackTrace st) { + unawaited(reportFrom(e, st, this)); + }, + ); } void _onDirectChatEvent(ChannelReadEvent event) { @@ -731,6 +761,7 @@ class ChatRoomCubit extends Cubit { Future close() async { EasyDebounce.cancel('client-typing'); EasyDebounce.cancel('searchChatRoom'); + EasyDebounce.cancel('chosen-plan-state-changed'); if (_presenceOnlineListener != null) { wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); _presenceOnlineListener = null; @@ -739,6 +770,7 @@ class ChatRoomCubit extends Cubit { _timer?.cancel(); await messagesSubscription?.cancel(); await typingSubscription?.cancel(); + await _chosenPlanStateSubscription?.cancel(); await _reconnectSubscription?.cancel(); super.close(); } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 99822267..6ea9f554 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -11,7 +11,6 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; -import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; @@ -24,11 +23,14 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; +import 'package:eatvio_app/features/chat/presentation/utils/chat_banner_resolver.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; -import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; +import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; @@ -63,8 +65,6 @@ class _ChatScreenState extends State { final TextEditingController textEditingController = TextEditingController(); final ChatRoomCubit chatRoomCubit = sl(); final List unreadMessageIds = []; - final showableChatBadgeStates = - ChosenPlanStateEnum.getShowableChatBadgeStates(); late AppLifecycleListener _listener; List pickedFiles = []; @@ -188,49 +188,23 @@ class _ChatScreenState extends State { ChosenPlanStateEnum.statePendingPayment, ChosenPlanStateEnum.stateInCoordination, ].contains(chosenPlan?.chosenPlanState); - PacketOptions? chosenOption; - if (chosenPlan?.optionType == 'options1') { - chosenOption = chosenPlan?.plan.packet1Options; - } else if (chosenPlan?.optionType == 'options2') { - chosenOption = chosenPlan?.plan.packet2Options; - } else if (chosenPlan?.optionType == 'options3') { - chosenOption = chosenPlan?.plan.packet3Options; - } - - final showAboutOrderButton = - state.chatRoom.chosenPlan != null && - state.chatRoom.chosenPlan!.chosenPlanState != + final chosenOption = chosenPlan?.chosenOption; + final progress = chosenPlan == null + ? null + : mapConsultationProgress( + state: chosenPlan.chosenPlanState, + hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false, + ); + final bannerType = resolveChatBanner( + progress: progress, + amIConsultant: amIConsultant, + hasCard: state.hasCard, + state: chosenPlan?.chosenPlanState, + ); + final showInfoButton = + chosenPlan != null && + chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator; - BannerType? bannerType; - if (chosenPlan == null || - chosenPlan.chosenPlanState == - ChosenPlanStateEnum.stateCanceledByTheInitiator) { - bannerType = null; - } else if (amIConsultant && - !state.hasCard && - chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { - bannerType = BannerType.card; - } else if (amIConsultant && - chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { - bannerType = BannerType.date; - } else if (!amIConsultant && - (chosenPlan.chosenPlanState == - ChosenPlanStateEnum.stateStartDateHasBeenSet || - chosenPlan.chosenPlanState == - ChosenPlanStateEnum.statePendingPayment)) { - bannerType = BannerType.payment; - } else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && - amIConsultant) { - bannerType = BannerType.collectCharge; - } else if (chosenPlan.chosenPlanState == - ChosenPlanStateEnum.stateLastDayFinished && - !amIConsultant) { - bannerType = BannerType.finishPlan; - } else if (chosenOption?.hasPersonalDiet == true && - amIConsultant && - chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) { - bannerType = BannerType.setPersonalDiet; - } return Scaffold( resizeToAvoidBottomInset: true, @@ -340,6 +314,28 @@ class _ChatScreenState extends State { Column( mainAxisSize: MainAxisSize.min, children: [ + if (progress != null) + ChatConsultationHeader( + progress: progress, + showInfoButton: showInfoButton, + onInfoTap: () { + if (amIConsultant) { + GoRouter.of(context).push( + InfoByOrderScreen.path, + extra: { + 'planId': chosenPlan?.id, + 'customerSlug': + state.chatRoom.users.receiver.slug, + }, + ); + return; + } + GoRouter.of(context).push( + InfoByOrderScreen.path, + extra: {'planId': chosenPlan?.id}, + ); + }, + ), Expanded( child: Container( color: appTheme.whiteA700, @@ -384,22 +380,9 @@ class _ChatScreenState extends State { 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; - } - } + final messageOption = isPlanOrderMessage + ? chosenPlan?.chosenOption + : null; return Column( children: [ @@ -450,21 +433,9 @@ class _ChatScreenState extends State { 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; - } - } + final messageOption = isPlanOrderMessage + ? chosenPlan?.chosenOption + : null; return Column( children: [ @@ -804,30 +775,6 @@ class _ChatScreenState extends State { ), ], ), - if (showAboutOrderButton) - Positioned( - top: 8, - right: 15, - left: 16, - child: AboutOrderButton( - onTap: () async { - if (amIConsultant) { - 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}, - ); - }, - ), - ), ], ), ), diff --git a/lib/features/chat/presentation/utils/chat_banner_resolver.dart b/lib/features/chat/presentation/utils/chat_banner_resolver.dart new file mode 100644 index 00000000..f2ab62c1 --- /dev/null +++ b/lib/features/chat/presentation/utils/chat_banner_resolver.dart @@ -0,0 +1,52 @@ +import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; + +BannerType? resolveChatBanner({ + required ConsultationProgress? progress, + required bool amIConsultant, + required bool hasCard, + required ChosenPlanStateEnum? state, +}) { + if (progress == null || + state == null || + progress.phase != ConsultationPhase.active) { + return null; + } + + if (amIConsultant && + !hasCard && + state == ChosenPlanStateEnum.stateInCoordination) { + return BannerType.card; + } + if (amIConsultant && state == ChosenPlanStateEnum.stateInCoordination) { + return BannerType.date; + } + if (!amIConsultant && + (state == ChosenPlanStateEnum.stateStartDateHasBeenSet || + state == ChosenPlanStateEnum.statePendingPayment)) { + return BannerType.payment; + } + if (state == ChosenPlanStateEnum.stateDone && amIConsultant) { + return BannerType.collectCharge; + } + if (state == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) { + return BannerType.finishPlan; + } + if (!amIConsultant) { + return null; + } + + if (state == ChosenPlanStateEnum.statePaid && + progress.dietStatus == PersonalDietStatus.notProposed) { + return BannerType.setPersonalDiet; + } + if ((state == ChosenPlanStateEnum.personalDietSet || + state == ChosenPlanStateEnum.stateStarted || + state == ChosenPlanStateEnum.stateInProgress) && + progress.dietStatus == PersonalDietStatus.proposed) { + return BannerType.changePersonalDiet; + } + + return null; +} diff --git a/lib/features/chat/presentation/widgets/chat_consultation_header.dart b/lib/features/chat/presentation/widgets/chat_consultation_header.dart new file mode 100644 index 00000000..718ef9f9 --- /dev/null +++ b/lib/features/chat/presentation/widgets/chat_consultation_header.dart @@ -0,0 +1,112 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart'; +import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +class ChatConsultationHeader extends StatelessWidget { + const ChatConsultationHeader({ + super.key, + required this.progress, + required this.showInfoButton, + required this.onInfoTap, + }); + + final ConsultationProgress progress; + final bool showInfoButton; + final VoidCallback onInfoTap; + + @override + Widget build(BuildContext context) { + final showPlank = progress.showProgressPlank; + final showChip = progress.hasChip; + if (!showPlank && !showChip && !showInfoButton) { + return const SizedBox.shrink(); + } + + final s = S.of(context); + + return Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: appTheme.commentBg, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (progress.phase == ConsultationPhase.active || showPlank) + Row( + children: [ + Expanded( + child: Text( + _title(s), + style: TextStyles.textMMedium.copyWith( + color: appTheme.black, + ), + ), + ), + if (showInfoButton) + _InfoButton( + onTap: onInfoTap, + ), + ], + ) + else if (showInfoButton) + Align( + alignment: Alignment.centerRight, + child: _InfoButton( + onTap: onInfoTap, + ), + ), + if (showPlank) ...[ + const SizedBox(height: 8), + ConsultationProgressPlank(progress: progress), + ], + if (showChip) ...[ + const SizedBox(height: 8), + PersonalDietStatusChip(progress: progress), + ], + ], + ), + ); + } + + String _title(S s) { + if (progress.isCanceled) return s.orderCanceled; + if (progress.isPaymentFailed) return s.payment_failed; + if (progress.isStepCompleted(ConsultationStep.finished)) return s.done; + if (progress.showProgressPlank) return s.order_in_progress; + return s.more_about_order; + } +} + +class _InfoButton extends StatelessWidget { + const _InfoButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: appTheme.whiteA700, + shape: BoxShape.circle, + ), + child: Icon( + Icons.info_outline_rounded, + size: 18, + color: appTheme.blueA400, + ), + ), + ); + } +} diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index 11db4288..434ac890 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -142,12 +142,37 @@ class _NotificationBannerState extends State { BannerType.setPersonalDiet => [ SizedBox( width: size.width * 0.6, - child: Text(s.createPersonalDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), + child: Text(s.suggestDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), ), const SizedBox(height: 12), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed(MyDietsScreen.name); + GoRouter.of(context).pushNamed( + MyDietsScreen.name, + queryParameters: {'initialTabIndex': '2'}, + ); + }, + child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), + ), + const SizedBox(height: 16), + ], + BannerType.changePersonalDiet => [ + SizedBox( + width: size.width * 0.6, + child: Text(s.change_personal_diet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), + ), + const SizedBox(height: 8), + SizedBox( + width: size.width * 0.6, + child: Text(s.personal_diet_replaces_tomorrow, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () { + GoRouter.of(context).pushNamed( + MyDietsScreen.name, + queryParameters: {'initialTabIndex': '2'}, + ); }, child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), ), @@ -240,4 +265,4 @@ class _StarIcon extends StatelessWidget { } } -enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet } +enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet, changePersonalDiet } diff --git a/lib/features/consultations/models/consultation_model.dart b/lib/features/consultations/models/consultation_model.dart index 50280cfe..dfd1c956 100644 --- a/lib/features/consultations/models/consultation_model.dart +++ b/lib/features/consultations/models/consultation_model.dart @@ -20,7 +20,7 @@ abstract class ConsultationModel with _$ConsultationModel { @JsonKey(name: 'user_profile_photo_url') required String userProfilePhotoUrl, @JsonKey(name: 'consultation_option_price') required int consultationOptionPrice, @JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate, - @JsonKey(name: 'end_date', fromJson: parseDate) required DateTime endDate, + @JsonKey(name: 'end_date', fromJson: parseDateOrNull) DateTime? endDate, @JsonKey(name: 'consultant_id') required int consultantId, required List diaries, @JsonKey(name: 'personal_diet_id') required int? personalDietId, @@ -40,6 +40,11 @@ abstract class DiaryModel with _$DiaryModel { factory DiaryModel.fromJson(Map json) => _$DiaryModelFromJson(json); } +DateTime? parseDateOrNull(dynamic date) { + if (date == null) return null; + return parseDate(date as String); +} + DateTime parseUnformattedDate(String date) { final dateChunks = date.split('-').map(int.parse).toList(); diff --git a/lib/features/consultations/models/consultation_progress.dart b/lib/features/consultations/models/consultation_progress.dart new file mode 100644 index 00000000..044642d5 --- /dev/null +++ b/lib/features/consultations/models/consultation_progress.dart @@ -0,0 +1,31 @@ +enum ConsultationStep { startDate, paid, inProgress, finished } + +enum PersonalDietStatus { notRequired, notProposed, proposed } + +enum ConsultationPhase { active, canceled, paymentFailed } + +class ConsultationProgress { + const ConsultationProgress({ + required this.completedSteps, + required this.showProgressPlank, + required this.dietStatus, + required this.phase, + }); + + final Set completedSteps; + final bool showProgressPlank; + final PersonalDietStatus dietStatus; + final ConsultationPhase phase; + + bool isStepCompleted(ConsultationStep step) => completedSteps.contains(step); + + bool get hasDietChip => dietStatus != PersonalDietStatus.notRequired; + + bool get hasStatusChip => phase != ConsultationPhase.active; + + bool get hasChip => hasDietChip || hasStatusChip; + + bool get isCanceled => phase == ConsultationPhase.canceled; + + bool get isPaymentFailed => phase == ConsultationPhase.paymentFailed; +} diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index 380ad7a9..3dc0274d 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -85,6 +85,25 @@ class _ConsultationCardWidgetState extends State { widget.consultationsModel.planTitle, style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500), ), + if (widget.consultationsModel.personalDietId != null) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: appTheme.transparentGreen, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + s.personal_diet_proposed, + style: TextStyles.textXSSemibold.copyWith( + color: appTheme.greenA700, + ), + ), + ), + ], ], ), ], diff --git a/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart b/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart new file mode 100644 index 00000000..bfc71d6d --- /dev/null +++ b/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart @@ -0,0 +1,111 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +class ConsultationProgressPlank extends StatelessWidget { + const ConsultationProgressPlank({super.key, required this.progress}); + + final ConsultationProgress progress; + + static const _steps = [ + ConsultationStep.startDate, + ConsultationStep.paid, + ConsultationStep.inProgress, + ConsultationStep.finished, + ]; + + @override + Widget build(BuildContext context) { + if (!progress.showProgressPlank) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + Row( + children: [ + for (var index = 0; index < _steps.length; index++) ...[ + _ProgressStepCircle( + index: index, + isCompleted: progress.isStepCompleted(_steps[index]), + ), + if (index != _steps.length - 1) + Expanded( + child: Container( + height: 2, + color: progress.isStepCompleted(_steps[index]) + ? appTheme.greenA700 + : appTheme.lightGray00, + ), + ), + ], + ], + ), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final step in _steps) + Expanded( + child: Text( + _stepLabel(S.of(context), step), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyles.textFS10FW500.copyWith( + color: progress.isStepCompleted(step) + ? appTheme.black + : appTheme.gray, + ), + ), + ), + ], + ), + ], + ); + } + + String _stepLabel(S s, ConsultationStep step) { + switch (step) { + case ConsultationStep.startDate: + return s.startDateHasBeenSet; + case ConsultationStep.paid: + return s.order_paid; + case ConsultationStep.inProgress: + return s.order_in_progress; + case ConsultationStep.finished: + return s.done; + } + } +} + +class _ProgressStepCircle extends StatelessWidget { + const _ProgressStepCircle({ + required this.index, + required this.isCompleted, + }); + + final int index; + final bool isCompleted; + + @override + Widget build(BuildContext context) { + return Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted ? appTheme.greenA700 : appTheme.lightGray00, + ), + child: isCompleted + ? Icon(Icons.check, size: 14, color: appTheme.whiteA700) + : Text( + '${index + 1}', + style: TextStyles.textFS10FW500.copyWith(color: appTheme.gray), + ), + ); + } +} diff --git a/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart b/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart new file mode 100644 index 00000000..0f33c501 --- /dev/null +++ b/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart @@ -0,0 +1,57 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +class PersonalDietStatusChip extends StatelessWidget { + const PersonalDietStatusChip({super.key, required this.progress}); + + final ConsultationProgress progress; + + @override + Widget build(BuildContext context) { + if (!progress.hasChip) { + return const SizedBox.shrink(); + } + + final s = S.of(context); + final (label, background, foreground) = _chipStyle(s); + + return Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: TextStyles.textXSSemibold.copyWith(color: foreground), + ), + ), + ); + } + + (String, Color, Color) _chipStyle(S s) { + if (progress.isCanceled) { + return (s.orderCanceled, appTheme.lightRed, appTheme.red); + } + if (progress.isPaymentFailed) { + return (s.payment_failed, appTheme.lightRed, appTheme.red); + } + if (progress.dietStatus == PersonalDietStatus.proposed) { + return ( + s.personal_diet_proposed, + appTheme.transparentGreen, + appTheme.greenA700, + ); + } + return ( + s.personal_diet_not_proposed, + appTheme.gray100, + appTheme.gray, + ); + } +} diff --git a/lib/features/consultations/utils/consultation_progress_mapper.dart b/lib/features/consultations/utils/consultation_progress_mapper.dart new file mode 100644 index 00000000..c37f6120 --- /dev/null +++ b/lib/features/consultations/utils/consultation_progress_mapper.dart @@ -0,0 +1,114 @@ +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; + +ConsultationProgress mapConsultationProgress({ + required ChosenPlanStateEnum state, + required bool hasPersonalDiet, +}) { + return ConsultationProgress( + completedSteps: _completedSteps(state), + showProgressPlank: _showProgressPlank(state), + dietStatus: _dietStatus(state, hasPersonalDiet), + phase: _phase(state), + ); +} + +Set _completedSteps(ChosenPlanStateEnum state) { + switch (state) { + case ChosenPlanStateEnum.stateInited: + case ChosenPlanStateEnum.stateConfirmedByConsultant: + case ChosenPlanStateEnum.stateInCoordination: + case ChosenPlanStateEnum.stateCanceledByTheInitiator: + return const {}; + case ChosenPlanStateEnum.stateStartDateHasBeenSet: + case ChosenPlanStateEnum.statePendingPayment: + case ChosenPlanStateEnum.statePaymentFailed: + return const {ConsultationStep.startDate}; + case ChosenPlanStateEnum.statePaid: + case ChosenPlanStateEnum.personalDietSet: + return const {ConsultationStep.startDate, ConsultationStep.paid}; + case ChosenPlanStateEnum.stateStarted: + case ChosenPlanStateEnum.stateInProgress: + case ChosenPlanStateEnum.stateLastDayStarted: + case ChosenPlanStateEnum.stateLastDayFinished: + case ChosenPlanStateEnum.stateComplained: + return const { + ConsultationStep.startDate, + ConsultationStep.paid, + ConsultationStep.inProgress, + }; + case ChosenPlanStateEnum.stateDone: + case ChosenPlanStateEnum.stateChargeWasCollected: + return const { + ConsultationStep.startDate, + ConsultationStep.paid, + ConsultationStep.inProgress, + ConsultationStep.finished, + }; + } +} + +bool _showProgressPlank(ChosenPlanStateEnum state) { + switch (state) { + case ChosenPlanStateEnum.stateInited: + case ChosenPlanStateEnum.stateConfirmedByConsultant: + case ChosenPlanStateEnum.personalDietSet: + case ChosenPlanStateEnum.stateChargeWasCollected: + case ChosenPlanStateEnum.stateCanceledByTheInitiator: + case ChosenPlanStateEnum.statePaymentFailed: + return false; + case ChosenPlanStateEnum.stateInCoordination: + case ChosenPlanStateEnum.stateStartDateHasBeenSet: + case ChosenPlanStateEnum.statePendingPayment: + case ChosenPlanStateEnum.statePaid: + case ChosenPlanStateEnum.stateStarted: + case ChosenPlanStateEnum.stateInProgress: + case ChosenPlanStateEnum.stateLastDayStarted: + case ChosenPlanStateEnum.stateLastDayFinished: + case ChosenPlanStateEnum.stateComplained: + case ChosenPlanStateEnum.stateDone: + return true; + } +} + +PersonalDietStatus _dietStatus( + ChosenPlanStateEnum state, + bool hasPersonalDiet, +) { + if (!hasPersonalDiet) { + return PersonalDietStatus.notRequired; + } + + switch (state) { + case ChosenPlanStateEnum.personalDietSet: + case ChosenPlanStateEnum.stateStarted: + case ChosenPlanStateEnum.stateInProgress: + case ChosenPlanStateEnum.stateLastDayStarted: + case ChosenPlanStateEnum.stateLastDayFinished: + case ChosenPlanStateEnum.stateComplained: + case ChosenPlanStateEnum.stateDone: + case ChosenPlanStateEnum.stateChargeWasCollected: + return PersonalDietStatus.proposed; + case ChosenPlanStateEnum.stateInited: + case ChosenPlanStateEnum.stateInCoordination: + case ChosenPlanStateEnum.stateConfirmedByConsultant: + case ChosenPlanStateEnum.stateStartDateHasBeenSet: + case ChosenPlanStateEnum.statePendingPayment: + case ChosenPlanStateEnum.statePaid: + return PersonalDietStatus.notProposed; + case ChosenPlanStateEnum.stateCanceledByTheInitiator: + case ChosenPlanStateEnum.statePaymentFailed: + return PersonalDietStatus.notRequired; + } +} + +ConsultationPhase _phase(ChosenPlanStateEnum state) { + switch (state) { + case ChosenPlanStateEnum.stateCanceledByTheInitiator: + return ConsultationPhase.canceled; + case ChosenPlanStateEnum.statePaymentFailed: + return ConsultationPhase.paymentFailed; + default: + return ConsultationPhase.active; + } +} diff --git a/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart b/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart index be5fcca9..1089146c 100644 --- a/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart +++ b/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart @@ -136,6 +136,11 @@ class MyDietsCubit extends Cubit { } } + void selectInitialTab(int index) { + if (index < 0 || index > 2) return; + emit(state.copyWith(selectedStateIndex: index)); + } + void changeSelectedStateIndex(int index) { emit(state.copyWith(selectedStateIndex: index)); if (state.activeDiets.isEmpty) { diff --git a/lib/features/my_diets/presentation/my_diets_screen.dart b/lib/features/my_diets/presentation/my_diets_screen.dart index 25662b1c..223f8eec 100644 --- a/lib/features/my_diets/presentation/my_diets_screen.dart +++ b/lib/features/my_diets/presentation/my_diets_screen.dart @@ -19,12 +19,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MyDietsScreen extends StatelessWidget { - const MyDietsScreen({super.key}); + const MyDietsScreen({super.key, this.initialTabIndex = 0}); static const name = 'myDietsScreen'; static const path = '/my-diets-screen'; + final int initialTabIndex; @override Widget build(BuildContext context) { - return BlocProvider(create: (context) => sl()..loadAllDiets(), child: MyDietsScreenContent()); + return BlocProvider( + create: (context) => + sl() + ..selectInitialTab(initialTabIndex) + ..loadAllDiets(), + child: MyDietsScreenContent(), + ); } } diff --git a/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart b/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart index 0afb7d3e..fb15fcea 100644 --- a/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart +++ b/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart @@ -9,38 +9,50 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class PersonalDietSuggestionCubit extends Cubit { final DietRepository dietRepository; final PersonalDietSuggestionRepository personalDietSuggestionService; - late final int dietId; + late int dietId; PersonalDietSuggestionCubit({required this.dietRepository, required this.personalDietSuggestionService}) : super(PersonalDietSuggestionState()); Future setDietName(int dietId) async { try { - emit(state.copyWith(isLoading: true, error: null)); + if (!isClosed) { + emit(state.copyWith(isLoading: true, error: null)); + } final diet = await dietRepository.getDiet(dietId); - emit(state.copyWith(isLoading: false, dietName: diet.name)); + if (!isClosed) { + emit(state.copyWith(isLoading: false, dietName: diet.name)); + } } catch (e, st) { await reportFrom(e, st, this); log(e.toString(), stackTrace: st); - emit(state.copyWith(isLoading: false, error: e.toString())); + if (!isClosed) { + emit(state.copyWith(isLoading: false, error: e.toString())); + } } } Future getConsultationWithPersonalDiets(int dietId) async { try { this.dietId = dietId; - emit(state.copyWith(isLoading: true, error: null)); - setDietName(dietId); + if (!isClosed) { + emit(state.copyWith(isLoading: true, error: null)); + } + await setDietName(dietId); final consultations = await personalDietSuggestionService.getConsultationWithPersonalDiets(); final consultationWithDiet = consultations.where((consultation) => consultation.personalDietId != null).toList(); final consultationWithoutDiet = consultations.where((consultation) => consultation.personalDietId == null).toList(); - emit(state.copyWith( - isLoading: false, - consultationsWithDiet: consultationWithDiet, - consultationsWithoutDiet: consultationWithoutDiet, - )); + if (!isClosed) { + emit(state.copyWith( + isLoading: false, + consultationsWithDiet: consultationWithDiet, + consultationsWithoutDiet: consultationWithoutDiet, + )); + } } catch (e, st) { await reportFrom(e, st, this); log(e.toString(), stackTrace: st); - emit(state.copyWith(isLoading: false, error: e.toString())); + if (!isClosed) { + emit(state.copyWith(isLoading: false, error: e.toString())); + } } } @@ -49,6 +61,7 @@ class PersonalDietSuggestionCubit extends Cubit { }) async { try { await personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId); + await getConsultationWithPersonalDiets(dietId); return true; } catch (e, st) { await reportFrom(e, st, this); @@ -62,6 +75,7 @@ class PersonalDietSuggestionCubit extends Cubit { }) async { try { await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId); + await getConsultationWithPersonalDiets(dietId); return true; } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart index a9fcef6e..2e74e582 100644 --- a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart +++ b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/consultations/models/consultation_model.dart import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -64,7 +65,11 @@ class ConsultationWidget extends StatelessWidget { ), ), Text( - 'Старт: ${consultationModel.userName}', + s.start_date_label( + DateFormat('dd.MM.yyyy').format( + consultationModel.startDate, + ), + ), style: TextStyles.textMMedium.copyWith( color: const Color(0xFF88949F), ), @@ -130,7 +135,7 @@ class ConsultationWidget extends StatelessWidget { onTap: () async { final result = await context .read() - .attachDietToConsultation( + .reattachDietToConsultation( consultationId: consultationModel.id, ); AppRouter.navigatorKey.currentContext?.pop(); diff --git a/lib/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart b/lib/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart index b966dafd..a622676e 100644 --- a/lib/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart +++ b/lib/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart @@ -11,13 +11,23 @@ class PersonalDietSuggestionRepository { } - Future attachPersonalDiet({required int consultationId, required int dietId}) async { - return personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId); - + Future attachPersonalDiet({ + required int consultationId, + required int dietId, + }) async { + await personalDietSuggestionService.attachPersonalDiet( + consultationId, + {'diet_id': dietId}, + ); } - Future reattachPersonalDiet({required int consultationId, required int dietId}) async { - return await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId); - + Future reattachPersonalDiet({ + required int consultationId, + required int dietId, + }) async { + await personalDietSuggestionService.reattachPersonalDiet( + consultationId, + {'diet_id': dietId}, + ); } } diff --git a/lib/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart b/lib/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart index 0a10c26e..245cbdbd 100644 --- a/lib/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart +++ b/lib/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart @@ -1,36 +1,40 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; +import 'package:retrofit/retrofit.dart'; -class PersonalDietSuggestionService { - final Dio dio; - PersonalDietSuggestionService(this.dio); +part 'personal_diet_suggestion_service.g.dart'; - Future getConsultationWithPersonalDiets() async { - try { - final response = await dio.get('/api/consultations', queryParameters: { - 'filters[plan][has_personal_diet][\$personal_diet]': '1', - 'filters[last_state][\$notIn][]': ['done', 'charge_was_collected', 'complained', 'last_day_finished', 'canceled_by_the_initiator', 'inited'], - 'sort[]': 'start_date:desc', - }); - return ConsultationPaginationModel.fromJson(response.data); - } catch (e) { - rethrow; - } - } +@RestApi() +abstract class PersonalDietSuggestionService { + factory PersonalDietSuggestionService(Dio dio, {String? baseUrl}) = + _PersonalDietSuggestionService; - Future attachPersonalDiet({required int consultationId, required int dietId}) async { - try { - await dio.post('/api/chosen_plans/$consultationId/attach_diet', data: {'diet_id': dietId}); - } catch (e) { - rethrow; - } - } + @GET('/api/consultations') + Future getConsultationWithPersonalDiets({ + @Query('filters[plan][has_personal_diet][\$personal_diet]') + String hasPersonalDiet = '1', + @Query('filters[last_state][\$notIn][]') + List notIn = const [ + 'done', + 'charge_was_collected', + 'complained', + 'last_day_finished', + 'last_day_started', + 'canceled_by_the_initiator', + 'inited', + ], + @Query('sort[]') List sort = const ['start_date:desc'], + }); - Future reattachPersonalDiet({required int consultationId, required int dietId}) async { - try { - await dio.post('/api/chosen_plans/$consultationId/re_attach_diet', data: {'diet_id': dietId}); - } catch (e) { - rethrow; - } - } + @POST('/api/chosen_plans/{id}/attach_diet') + Future attachPersonalDiet( + @Path('id') int consultationId, + @Body() Map body, + ); + + @POST('/api/chosen_plans/{id}/re_attach_diet') + Future reattachPersonalDiet( + @Path('id') int consultationId, + @Body() Map body, + ); } diff --git a/lib/features/plans/models/chosen_plan_model.dart b/lib/features/plans/models/chosen_plan_model.dart index f70f1cd2..9ac84c85 100644 --- a/lib/features/plans/models/chosen_plan_model.dart +++ b/lib/features/plans/models/chosen_plan_model.dart @@ -21,6 +21,21 @@ abstract class ChosenPlanModel with _$ChosenPlanModel { factory ChosenPlanModel.fromJson(Map json) => _$ChosenPlanModelFromJson(json); } +extension ChosenPlanModelX on ChosenPlanModel { + PacketOptions? get chosenOption { + switch (optionType) { + case 'options1': + return plan.packet1Options; + case 'options2': + return plan.packet2Options; + case 'options3': + return plan.packet3Options; + default: + return null; + } + } +} + ChosenPlanModel? parseChosenPlan(dynamic json) { if (json is Map) { return ChosenPlanModel.fromJson(json); diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 8929ab56..2a2f9203 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -30,24 +30,26 @@ class MessageLookup extends MessageLookupByLibrary { static String m3(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m4(step) => "шаг ${step} из 2"; + static String m4(date) => "Start: ${date}"; - static String m5(planName, name) => + static String m5(step) => "шаг ${step} из 2"; + + static String m6(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m6(planName) => + static String m7(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m7(name, planName) => + static String m8(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m8(name, planName) => + static String m9(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m9(name, planName) => + static String m10(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m10(planName) => "Вы отменили подписку на план «${planName}»"; + static String m11(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -170,6 +172,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"), "changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"), + "change_personal_diet": MessageLookupByLibrary.simpleMessage( + "Change personal diet", + ), "chargeApproved": MessageLookupByLibrary.simpleMessage("Выплата одобрена"), "chargeCollectionFailed": MessageLookupByLibrary.simpleMessage( "Не удалось оформить выплату", @@ -635,6 +640,9 @@ class MessageLookup extends MessageLookupByLibrary { "order": MessageLookupByLibrary.simpleMessage("Порядок"), "orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"), "order_created": MessageLookupByLibrary.simpleMessage("Заказ создан"), + "order_in_progress": MessageLookupByLibrary.simpleMessage( + "Order in progress", + ), "order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"), "order_price": MessageLookupByLibrary.simpleMessage("Сумма заказа"), "order_status": MessageLookupByLibrary.simpleMessage("Статус заказа"), @@ -665,6 +673,15 @@ class MessageLookup extends MessageLookupByLibrary { "personalDietCreating": MessageLookupByLibrary.simpleMessage( "Составление индивидуальной диеты", ), + "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( + "Personal diet not proposed", + ), + "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( + "Personal diet proposed", + ), + "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( + "The change takes effect tomorrow", + ), "personal_diet_set": MessageLookupByLibrary.simpleMessage( "Персональная диета установлена", ), @@ -821,12 +838,13 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), + "start_date_label": m4, "statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "step": MessageLookupByLibrary.simpleMessage("Шаг"), "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m4, + "step_widget": m5, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -851,8 +869,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m5, - "tomorrowYourPlanWillStart": m6, + "tommorowPlanWithUserWillStart": m6, + "tomorrowYourPlanWillStart": m7, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -886,7 +904,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m7, + "userCanceldThePlanSubscription": m8, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -894,8 +912,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m8, - "userSubscribedToPlan": m9, + "userPaidThePlan": m9, + "userSubscribedToPlan": m10, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1025,7 +1043,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m10, + "youCanceledSubscriptionToPlan": m11, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 91ba0fac..34a668d5 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -23,6 +23,8 @@ class MessageLookup extends MessageLookupByLibrary { static String m3(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; + static String m4(date) => "Старт: ${date}"; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Moscow": MessageLookupByLibrary.simpleMessage("Москва"), @@ -66,6 +68,10 @@ class MessageLookup extends MessageLookupByLibrary { "Если вы любите мучные продукты, но хотите придерживаться диеты, вам подходит диета с добавленим мучных блюд на основе цельнозерновых вариантов. В них больше сложных углеводов и клетчатки, что способствует длительному чувству сытости и поддерживает здоровье пищеварительной системы.", ), "carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"), + "changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"), + "change_personal_diet": MessageLookupByLibrary.simpleMessage( + "Заменить диету", + ), "chat": MessageLookupByLibrary.simpleMessage("Чат"), "chest": MessageLookupByLibrary.simpleMessage("Грудь"), "chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"), @@ -78,6 +84,10 @@ class MessageLookup extends MessageLookupByLibrary { "choose_plan_duration": MessageLookupByLibrary.simpleMessage( "Выберите срок плана", ), + "choose_plan_start_date_with_personal_diet": + MessageLookupByLibrary.simpleMessage( + "Выберите дату старта плана питания с учетом составления персональной диеты", + ), "choose_start_diet_date": MessageLookupByLibrary.simpleMessage( "Выберите дату старты диеты. Она отобразиться у Вас в дневнике", ), @@ -126,6 +136,7 @@ class MessageLookup extends MessageLookupByLibrary { "do_you_take_pills": MessageLookupByLibrary.simpleMessage( "Принимаете ли вы лекарства или бады?", ), + "done": MessageLookupByLibrary.simpleMessage("Завершен"), "down_pressure": MessageLookupByLibrary.simpleMessage( "Нижние показатели давления:", ), @@ -192,6 +203,9 @@ class MessageLookup extends MessageLookupByLibrary { "from_a_to_z": MessageLookupByLibrary.simpleMessage("Название: А-Я"), "from_z_to_a": MessageLookupByLibrary.simpleMessage("Название: Я-А"), "gallery": MessageLookupByLibrary.simpleMessage("Галерея"), + "goToMyDiets": MessageLookupByLibrary.simpleMessage( + "Перейти к моим диетам", + ), "gr": MessageLookupByLibrary.simpleMessage("гр"), "grams": MessageLookupByLibrary.simpleMessage("гр"), "have_promo_code": MessageLookupByLibrary.simpleMessage( @@ -284,6 +298,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "option_title": MessageLookupByLibrary.simpleMessage("Заголовок опции"), "order": MessageLookupByLibrary.simpleMessage("Порядок"), + "orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"), + "order_in_progress": MessageLookupByLibrary.simpleMessage("Заказ в работе"), + "order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"), "password": MessageLookupByLibrary.simpleMessage("Пароль"), "password_confirmation": MessageLookupByLibrary.simpleMessage( "Подтвердите пароль", @@ -297,8 +314,21 @@ class MessageLookup extends MessageLookupByLibrary { "password_short": MessageLookupByLibrary.simpleMessage( "В пароле должно быть минимум 6 символов", ), + "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"), + "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( + "Диета не предложена", + ), + "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( + "Диета предложена", + ), + "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( + "Замена действует со следующего дня", + ), + "personal_diet_set": MessageLookupByLibrary.simpleMessage( + "Персональная диета установлена", + ), "personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"), "photo": MessageLookupByLibrary.simpleMessage("Фото"), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), @@ -364,7 +394,12 @@ class MessageLookup extends MessageLookupByLibrary { "План питания для похудения должен быть разработан с учётом индивидуальных особенностей организма, возраста, пола, уровня физической активности и состояния здоровья. Общие принципы: \n● Снижение калорийности; \n● Сбалансированный рацион; \n● Разнообразие продуктов; \n● Питьевой режим; \n● Ограничение вредных продуктов;", ), "standard": MessageLookupByLibrary.simpleMessage("Стандарт"), + "startDateHasBeenSet": MessageLookupByLibrary.simpleMessage( + "Выбрана дата старта", + ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), + "start_date_label": m4, + "suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"), "tell_your_schedule": MessageLookupByLibrary.simpleMessage( "Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:", ), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index f6d24a07..69416eec 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4443,6 +4443,66 @@ class S { args: [], ); } + + /// `Order in progress` + String get order_in_progress { + return Intl.message( + 'Order in progress', + name: 'order_in_progress', + desc: '', + args: [], + ); + } + + /// `Personal diet proposed` + String get personal_diet_proposed { + return Intl.message( + 'Personal diet proposed', + name: 'personal_diet_proposed', + desc: '', + args: [], + ); + } + + /// `Personal diet not proposed` + String get personal_diet_not_proposed { + return Intl.message( + 'Personal diet not proposed', + name: 'personal_diet_not_proposed', + desc: '', + args: [], + ); + } + + /// `Change personal diet` + String get change_personal_diet { + return Intl.message( + 'Change personal diet', + name: 'change_personal_diet', + desc: '', + args: [], + ); + } + + /// `The change takes effect tomorrow` + String get personal_diet_replaces_tomorrow { + return Intl.message( + 'The change takes effect tomorrow', + name: 'personal_diet_replaces_tomorrow', + desc: '', + args: [], + ); + } + + /// `Start: {date}` + String start_date_label(String date) { + return Intl.message( + 'Start: $date', + name: 'start_date_label', + desc: '', + args: [date], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index bd2406d8..910e15e7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -526,6 +526,17 @@ "video_call_close": "Close", "video_call_notification_in_progress": "Call in progress", "video_call_notification_return": "Return to call", - "video_call_notification_end": "End call" + "video_call_notification_end": "End call", + "order_in_progress": "Order in progress", + "personal_diet_proposed": "Personal diet proposed", + "personal_diet_not_proposed": "Personal diet not proposed", + "change_personal_diet": "Change personal diet", + "personal_diet_replaces_tomorrow": "The change takes effect tomorrow", + "start_date_label": "Start: {date}", + "@start_date_label": { + "placeholders": { + "date": {"type": "String"} + } + } } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 5d729fe6..04a2af45 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -271,5 +271,26 @@ "video_call_notification_in_progress": "Звонок идёт", "video_call_notification_return": "Вернуться к звонку", "video_call_notification_end": "Завершить звонок", - "blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px" + "blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px", + "startDateHasBeenSet": "Выбрана дата старта", + "order_paid": "Заказ оплачен", + "done": "Завершен", + "orderCanceled": "Заказ отменен", + "payment_failed": "Оплата не прошла", + "personal_diet_set": "Персональная диета установлена", + "suggestDiet": "Предложить диету", + "changeDiet": "Заменить диету", + "goToMyDiets": "Перейти к моим диетам", + "choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты", + "order_in_progress": "Заказ в работе", + "personal_diet_proposed": "Диета предложена", + "personal_diet_not_proposed": "Диета не предложена", + "change_personal_diet": "Заменить диету", + "personal_diet_replaces_tomorrow": "Замена действует со следующего дня", + "start_date_label": "Старт: {date}", + "@start_date_label": { + "placeholders": { + "date": {"type": "String"} + } + } } 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 7250b6d1..1453e8c2 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -78,6 +78,66 @@ void main() { meta: const ChatMetaModel(lastPage: 1), ); + const planForChosenPlanUpdates = 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, + ); + + ChosenPlanModel buildChosenPlan( + int id, + ChosenPlanStateEnum chosenPlanState, + ) { + return ChosenPlanModel( + id: id, + optionType: 'options1', + startDate: DateTime.now(), + chosenPlanState: chosenPlanState, + chatId: 'chat-id', + consultantId: 1, + plan: planForChosenPlanUpdates, + ); + } + + void stubChatChannel({ + required Stream chosenPlanStream, + }) { + when(mockWsManager.client).thenReturn(MockPusherChannelsClient()); + when( + mockWsManager.directChatStream, + ).thenAnswer((_) => const Stream.empty()); + when( + mockWsManager.clientTypingStream, + ).thenAnswer((_) => const Stream.empty()); + when( + mockWsManager.chosenPlanStateChangedStream, + ).thenAnswer((_) => chosenPlanStream); + } + setUp(() { mockChatRepository = MockChatRepository(); mockCardsRepository = MockCardsRepository(); @@ -721,6 +781,160 @@ void main() { }, ); + test( + 'chosenPlanStateChangedStream - перезапрашивает план и обновляет state', + () async { + const uuid = 'room-uuid'; + const planId = 321; + final chosenPlan = buildChosenPlan( + planId, + ChosenPlanStateEnum.stateStarted, + ); + final updatedPlan = chosenPlan.copyWith( + chosenPlanState: ChosenPlanStateEnum.statePaid, + ); + final controller = StreamController.broadcast(); + + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom.copyWith(chosenPlanId: planId)); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + stubChatChannel(chosenPlanStream: controller.stream); + + await chatRoomCubit.initChat(uuid); + expect(chatRoomCubit.state, isA()); + + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => updatedPlan); + clearInteractions(mockChatRepository); + + controller.add(_FakeChannelReadEvent()); + await Future.delayed(const Duration(milliseconds: 350)); + + verify(mockChatRepository.getChosenPlan(id: planId)).called(1); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect( + state.chatRoom.chosenPlan?.chosenPlanState, + ChosenPlanStateEnum.statePaid, + ); + + await controller.close(); + }, + ); + + test( + 'chosenPlanStateChangedStream - использует chosenPlan.id если chosenPlanId null', + () async { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872'; + const planId = 654; + final chosenPlan = buildChosenPlan( + planId, + ChosenPlanStateEnum.stateStarted, + ); + final updatedPlan = chosenPlan.copyWith( + chosenPlanState: ChosenPlanStateEnum.statePaid, + ); + final controller = StreamController.broadcast(); + + when( + mockChatRepository.resolveOrderIdToConversation(orderId: orderId), + ).thenAnswer( + (_) async => OrderIdToConversationModel( + chosenPlanId: planId, + conversationUuid: conversationUuid, + ), + ); + when( + mockChatRepository.getChatRoom(conversationUuid), + ).thenAnswer((_) async => testChatRoom); + when( + mockChatRepository.getChatMessages(conversationUuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + stubChatChannel(chosenPlanStream: controller.stream); + + await chatRoomCubit.initChat(null, orderId: orderId); + final loaded = chatRoomCubit.state as ChatRoomStateLoaded; + expect(loaded.chatRoom.chosenPlanId, isNull); + expect(loaded.chatRoom.chosenPlan?.id, planId); + + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => updatedPlan); + clearInteractions(mockChatRepository); + + controller.add(_FakeChannelReadEvent()); + await Future.delayed(const Duration(milliseconds: 350)); + + verify(mockChatRepository.getChosenPlan(id: planId)).called(1); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect( + state.chatRoom.chosenPlan?.chosenPlanState, + ChosenPlanStateEnum.statePaid, + ); + + await controller.close(); + }, + ); + + test( + 'chosenPlanStateChangedStream - дебаунсит повторные события', + () async { + const uuid = 'room-uuid'; + const planId = 987; + final chosenPlan = buildChosenPlan( + planId, + ChosenPlanStateEnum.stateStarted, + ); + final updatedPlan = chosenPlan.copyWith( + chosenPlanState: ChosenPlanStateEnum.statePaid, + ); + final controller = StreamController.broadcast(); + + when( + mockChatRepository.getChatRoom(uuid), + ).thenAnswer((_) async => testChatRoom.copyWith(chosenPlanId: planId)); + when( + mockChatRepository.getChatMessages(uuid, 1), + ).thenAnswer((_) async => testChatMessages); + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => chosenPlan); + stubChatChannel(chosenPlanStream: controller.stream); + + await chatRoomCubit.initChat(uuid); + expect(chatRoomCubit.state, isA()); + + when( + mockChatRepository.getChosenPlan(id: planId), + ).thenAnswer((_) async => updatedPlan); + clearInteractions(mockChatRepository); + + controller + ..add(_FakeChannelReadEvent()) + ..add(_FakeChannelReadEvent()); + await Future.delayed(const Duration(milliseconds: 350)); + + verify(mockChatRepository.getChosenPlan(id: planId)).called(1); + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect( + state.chatRoom.chosenPlan?.chosenPlanState, + ChosenPlanStateEnum.statePaid, + ); + + await controller.close(); + }, + ); + test('choseStartPlanDate - устанавливает дату начала плана', () async { const uuid = 'room-uuid'; const planId = 123; @@ -2578,3 +2792,8 @@ void main() { ); }); } + +class _FakeChannelReadEvent extends Fake implements ChannelReadEvent { + @override + dynamic get data => null; +} diff --git a/test/features/chat/presentation/utils/chat_banner_resolver_test.dart b/test/features/chat/presentation/utils/chat_banner_resolver_test.dart new file mode 100644 index 00000000..7f9a11a4 --- /dev/null +++ b/test/features/chat/presentation/utils/chat_banner_resolver_test.dart @@ -0,0 +1,232 @@ +import 'package:eatvio_app/features/chat/presentation/utils/chat_banner_resolver.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ConsultationProgress _progress( + ChosenPlanStateEnum state, { + bool hasPersonalDiet = false, +}) { + return mapConsultationProgress( + state: state, + hasPersonalDiet: hasPersonalDiet, + ); +} + +void main() { + group('resolveChatBanner — карта консультанта', () { + test('in_coordination без карты просит добавить карту', () { + final banner = resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.stateInCoordination), + amIConsultant: true, + hasCard: false, + state: ChosenPlanStateEnum.stateInCoordination, + ); + + expect(banner, BannerType.card); + }); + + test('in_coordination с картой просит выбрать дату', () { + final banner = resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.stateInCoordination), + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.stateInCoordination, + ); + + expect(banner, BannerType.date); + }); + + test('paid + notProposed + диета просит предложить диету', () { + final banner = resolveChatBanner( + progress: _progress( + ChosenPlanStateEnum.statePaid, + hasPersonalDiet: true, + ), + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.statePaid, + ); + + expect(banner, BannerType.setPersonalDiet); + }); + + test( + 'personal_diet_setted/started/in_progress + proposed просят сменить диету', + () { + const states = [ + ChosenPlanStateEnum.personalDietSet, + ChosenPlanStateEnum.stateStarted, + ChosenPlanStateEnum.stateInProgress, + ]; + + for (final state in states) { + final banner = resolveChatBanner( + progress: _progress(state, hasPersonalDiet: true), + amIConsultant: true, + hasCard: true, + state: state, + ); + + expect( + banner, + BannerType.changePersonalDiet, + reason: 'state=${state.name}', + ); + } + }, + ); + + test('last_day_finished не просит менять диету (нет баннера)', () { + final banner = resolveChatBanner( + progress: _progress( + ChosenPlanStateEnum.stateLastDayFinished, + hasPersonalDiet: true, + ), + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.stateLastDayFinished, + ); + + expect(banner, isNot(BannerType.changePersonalDiet)); + expect(banner, isNull); + }); + + test('done не просит менять диету (collectCharge)', () { + final banner = resolveChatBanner( + progress: _progress( + ChosenPlanStateEnum.stateDone, + hasPersonalDiet: true, + ), + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.stateDone, + ); + + expect(banner, isNot(BannerType.changePersonalDiet)); + expect(banner, BannerType.collectCharge); + }); + }); + + group('resolveChatBanner — карта клиента', () { + test('start_date_has_been_set просит оплатить', () { + final banner = resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.stateStartDateHasBeenSet), + amIConsultant: false, + hasCard: true, + state: ChosenPlanStateEnum.stateStartDateHasBeenSet, + ); + + expect(banner, BannerType.payment); + }); + + test('pending_payment просит оплатить', () { + final banner = resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.statePendingPayment), + amIConsultant: false, + hasCard: true, + state: ChosenPlanStateEnum.statePendingPayment, + ); + + expect(banner, BannerType.payment); + }); + + test('paid + notProposed не показывает diet-баннер клиенту', () { + final banner = resolveChatBanner( + progress: _progress( + ChosenPlanStateEnum.statePaid, + hasPersonalDiet: true, + ), + amIConsultant: false, + hasCard: true, + state: ChosenPlanStateEnum.statePaid, + ); + + expect(banner, isNull); + }); + + test( + 'personal_diet_setted + proposed не показывает diet-баннер клиенту', + () { + final banner = resolveChatBanner( + progress: _progress( + ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + ), + amIConsultant: false, + hasCard: true, + state: ChosenPlanStateEnum.personalDietSet, + ); + + expect(banner, isNull); + }, + ); + }); + + group('resolveChatBanner — терминальные фазы', () { + test('canceled не показывает баннер', () { + expect( + resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.stateCanceledByTheInitiator), + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.stateCanceledByTheInitiator, + ), + isNull, + ); + expect( + resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.stateCanceledByTheInitiator), + amIConsultant: false, + hasCard: true, + state: ChosenPlanStateEnum.stateCanceledByTheInitiator, + ), + isNull, + ); + }); + + test('payment_failed не показывает баннер', () { + expect( + resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.statePaymentFailed), + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.statePaymentFailed, + ), + isNull, + ); + expect( + resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.statePaymentFailed), + amIConsultant: false, + hasCard: true, + state: ChosenPlanStateEnum.statePaymentFailed, + ), + isNull, + ); + }); + + test('нет progress или state — нет баннера', () { + expect( + resolveChatBanner( + progress: null, + amIConsultant: true, + hasCard: true, + state: ChosenPlanStateEnum.statePaid, + ), + isNull, + ); + expect( + resolveChatBanner( + progress: _progress(ChosenPlanStateEnum.statePaid), + amIConsultant: true, + hasCard: true, + state: null, + ), + isNull, + ); + }); + }); +} diff --git a/test/features/chat/presentation/widgets/chat_consultation_header_test.dart b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart new file mode 100644 index 00000000..18d154e4 --- /dev/null +++ b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart @@ -0,0 +1,82 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart'; +import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart'; +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart'; +import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + group('ChatConsultationHeader', () { + testWidgets('вызывает onInfoTap по тапу на info', (tester) async { + var tapped = 0; + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.stateInited, + hasPersonalDiet: false, + ); + + await tester.pumpWidget( + _wrap( + ChatConsultationHeader( + progress: progress, + showInfoButton: true, + onInfoTap: () => tapped++, + ), + ), + ); + + await tester.tap(find.byIcon(Icons.info_outline_rounded)); + await tester.pump(); + + expect(tapped, 1); + }); + + testWidgets( + 'при showProgressPlank=false и proposed видны заголовок и чип, плашки нет', + (tester) async { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + ); + expect(progress.showProgressPlank, isFalse); + expect(progress.dietStatus, PersonalDietStatus.proposed); + + await tester.pumpWidget( + _wrap( + ChatConsultationHeader( + progress: progress, + showInfoButton: false, + onInfoTap: () {}, + ), + ), + ); + + final s = S.of(tester.element(find.byType(ChatConsultationHeader))); + + expect(find.text(s.more_about_order), findsOneWidget); + expect(find.byType(PersonalDietStatusChip), findsOneWidget); + expect(find.text(s.personal_diet_proposed), findsOneWidget); + expect(find.byType(ConsultationProgressPlank), findsNothing); + expect(find.byIcon(Icons.check), findsNothing); + }, + ); + }); +} diff --git a/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart b/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart new file mode 100644 index 00000000..a7e81ce1 --- /dev/null +++ b/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart @@ -0,0 +1,71 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart'; +import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + group('ConsultationProgressPlank', () { + testWidgets('не строит шаги при showProgressPlank=false', (tester) async { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + ); + expect(progress.showProgressPlank, isFalse); + + await tester.pumpWidget( + _wrap(ConsultationProgressPlank(progress: progress)), + ); + + expect( + find.descendant( + of: find.byType(ConsultationProgressPlank), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect(find.byIcon(Icons.check), findsNothing); + expect(find.byType(Text), findsNothing); + }); + + testWidgets( + 'рисует 4 шага с иконками и подписями при showProgressPlank=true', + (tester) async { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.stateDone, + hasPersonalDiet: false, + ); + expect(progress.showProgressPlank, isTrue); + + await tester.pumpWidget( + _wrap(ConsultationProgressPlank(progress: progress)), + ); + + final s = S.of(tester.element(find.byType(ConsultationProgressPlank))); + + expect(find.byIcon(Icons.check), findsNWidgets(4)); + expect(find.text(s.startDateHasBeenSet), findsOneWidget); + expect(find.text(s.order_paid), findsOneWidget); + expect(find.text(s.order_in_progress), findsOneWidget); + expect(find.text(s.done), findsOneWidget); + }, + ); + }); +} diff --git a/test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart b/test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart new file mode 100644 index 00000000..d8fbb9f1 --- /dev/null +++ b/test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart @@ -0,0 +1,88 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart'; +import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold(body: Center(child: child)), + ); +} + +Widget _chip(ChosenPlanStateEnum state, {bool hasPersonalDiet = true}) { + return PersonalDietStatusChip( + progress: mapConsultationProgress( + state: state, + hasPersonalDiet: hasPersonalDiet, + ), + ); +} + +void main() { + group('PersonalDietStatusChip', () { + testWidgets('notRequired + active ничего не показывает', (tester) async { + await tester.pumpWidget( + _wrap(_chip(ChosenPlanStateEnum.stateInited, hasPersonalDiet: false)), + ); + + expect( + find.descendant( + of: find.byType(PersonalDietStatusChip), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('proposed показывает personal_diet_proposed', (tester) async { + await tester.pumpWidget(_wrap(_chip(ChosenPlanStateEnum.stateStarted))); + + final s = S.of(tester.element(find.byType(PersonalDietStatusChip))); + + expect(find.text(s.personal_diet_proposed), findsOneWidget); + }); + + testWidgets('notProposed показывает personal_diet_not_proposed', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_chip(ChosenPlanStateEnum.statePaid))); + + final s = S.of(tester.element(find.byType(PersonalDietStatusChip))); + + expect(find.text(s.personal_diet_not_proposed), findsOneWidget); + }); + + testWidgets('canceled показывает orderCanceled', (tester) async { + await tester.pumpWidget( + _wrap(_chip(ChosenPlanStateEnum.stateCanceledByTheInitiator)), + ); + + final s = S.of(tester.element(find.byType(PersonalDietStatusChip))); + + expect(find.text(s.orderCanceled), findsOneWidget); + }); + + testWidgets('paymentFailed показывает payment_failed', (tester) async { + await tester.pumpWidget( + _wrap(_chip(ChosenPlanStateEnum.statePaymentFailed)), + ); + + final s = S.of(tester.element(find.byType(PersonalDietStatusChip))); + + expect(find.text(s.payment_failed), findsOneWidget); + }); + }); +} diff --git a/test/features/consultations/utils/consultation_progress_mapper_test.dart b/test/features/consultations/utils/consultation_progress_mapper_test.dart new file mode 100644 index 00000000..09ee87a8 --- /dev/null +++ b/test/features/consultations/utils/consultation_progress_mapper_test.dart @@ -0,0 +1,279 @@ +import 'package:eatvio_app/features/consultations/models/consultation_progress.dart'; +import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:flutter_test/flutter_test.dart'; + +typedef _Expected = ({ + Set steps, + bool plank, + PersonalDietStatus diet, + ConsultationPhase phase, +}); + +const _startDate = ConsultationStep.startDate; +const _paid = ConsultationStep.paid; +const _inProgress = ConsultationStep.inProgress; +const _finished = ConsultationStep.finished; + +final Map _expectations = { + ChosenPlanStateEnum.stateInited: ( + steps: {}, + plank: false, + diet: PersonalDietStatus.notProposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateStartDateHasBeenSet: ( + steps: {_startDate}, + plank: true, + diet: PersonalDietStatus.notProposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateInCoordination: ( + steps: {}, + plank: true, + diet: PersonalDietStatus.notProposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateStarted: ( + steps: {_startDate, _paid, _inProgress}, + plank: true, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateInProgress: ( + steps: {_startDate, _paid, _inProgress}, + plank: true, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.statePendingPayment: ( + steps: {_startDate}, + plank: true, + diet: PersonalDietStatus.notProposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateConfirmedByConsultant: ( + steps: {}, + plank: false, + diet: PersonalDietStatus.notProposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.statePaid: ( + steps: {_startDate, _paid}, + plank: true, + diet: PersonalDietStatus.notProposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateCanceledByTheInitiator: ( + steps: {}, + plank: false, + diet: PersonalDietStatus.notRequired, + phase: ConsultationPhase.canceled, + ), + ChosenPlanStateEnum.stateLastDayStarted: ( + steps: {_startDate, _paid, _inProgress}, + plank: true, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateLastDayFinished: ( + steps: {_startDate, _paid, _inProgress}, + plank: true, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateComplained: ( + steps: {_startDate, _paid, _inProgress}, + plank: true, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateDone: ( + steps: {_startDate, _paid, _inProgress, _finished}, + plank: true, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.personalDietSet: ( + steps: {_startDate, _paid}, + plank: false, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.stateChargeWasCollected: ( + steps: {_startDate, _paid, _inProgress, _finished}, + plank: false, + diet: PersonalDietStatus.proposed, + phase: ConsultationPhase.active, + ), + ChosenPlanStateEnum.statePaymentFailed: ( + steps: {_startDate}, + plank: false, + diet: PersonalDietStatus.notRequired, + phase: ConsultationPhase.paymentFailed, + ), +}; + +void main() { + group('mapConsultationProgress — все состояния', () { + test('таблица покрывает ровно все 16 состояний', () { + expect(_expectations.length, 16); + expect(_expectations.keys.toSet(), ChosenPlanStateEnum.values.toSet()); + }); + + for (final entry in _expectations.entries) { + test('${entry.key.name} маппится во все поля ConsultationProgress', () { + final progress = mapConsultationProgress( + state: entry.key, + hasPersonalDiet: true, + ); + + expect(progress.completedSteps, unorderedEquals(entry.value.steps)); + expect(progress.showProgressPlank, entry.value.plank); + expect(progress.dietStatus, entry.value.diet); + expect(progress.phase, entry.value.phase); + }); + } + }); + + group('mapConsultationProgress — ключевые сценарии', () { + test('in_coordination показывает плашку без завершённых шагов', () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.stateInCoordination, + hasPersonalDiet: true, + ); + + expect(progress.showProgressPlank, isTrue); + expect(progress.completedSteps, isEmpty); + }); + + test( + 'personal_diet_setted не показывает плашку и завершает startDate+paid', + () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + ); + + expect(progress.showProgressPlank, isFalse); + expect(progress.completedSteps, unorderedEquals([_startDate, _paid])); + }, + ); + + test( + 'started/in_progress/last_day_started/last_day_finished/complained — 3 шага', + () { + const states = [ + ChosenPlanStateEnum.stateStarted, + ChosenPlanStateEnum.stateInProgress, + ChosenPlanStateEnum.stateLastDayStarted, + ChosenPlanStateEnum.stateLastDayFinished, + ChosenPlanStateEnum.stateComplained, + ]; + + for (final state in states) { + final progress = mapConsultationProgress( + state: state, + hasPersonalDiet: true, + ); + + expect( + progress.completedSteps, + unorderedEquals([_startDate, _paid, _inProgress]), + reason: 'state=${state.name}', + ); + } + }, + ); + + test('done/charge_was_collected — 4 шага', () { + const states = [ + ChosenPlanStateEnum.stateDone, + ChosenPlanStateEnum.stateChargeWasCollected, + ]; + + for (final state in states) { + final progress = mapConsultationProgress( + state: state, + hasPersonalDiet: true, + ); + + expect( + progress.completedSteps, + unorderedEquals([_startDate, _paid, _inProgress, _finished]), + reason: 'state=${state.name}', + ); + } + }); + + test( + 'canceled — фаза canceled и dietStatus notRequired даже при диете', + () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.stateCanceledByTheInitiator, + hasPersonalDiet: true, + ); + + expect(progress.phase, ConsultationPhase.canceled); + expect(progress.isCanceled, isTrue); + expect(progress.dietStatus, PersonalDietStatus.notRequired); + }, + ); + + test('payment_failed — фаза paymentFailed и только startDate', () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.statePaymentFailed, + hasPersonalDiet: true, + ); + + expect(progress.phase, ConsultationPhase.paymentFailed); + expect(progress.isPaymentFailed, isTrue); + expect(progress.completedSteps, unorderedEquals([_startDate])); + }); + + test('hasPersonalDiet=false даёт notRequired для любого состояния', () { + for (final state in ChosenPlanStateEnum.values) { + final progress = mapConsultationProgress( + state: state, + hasPersonalDiet: false, + ); + + expect( + progress.dietStatus, + PersonalDietStatus.notRequired, + reason: 'state=${state.name}', + ); + expect(progress.hasDietChip, isFalse, reason: 'state=${state.name}'); + } + }); + + test('paid с диетой даёт notProposed', () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.statePaid, + hasPersonalDiet: true, + ); + + expect(progress.dietStatus, PersonalDietStatus.notProposed); + }); + + test('personal_diet_setted/started/proposed дают proposed', () { + const states = [ + ChosenPlanStateEnum.personalDietSet, + ChosenPlanStateEnum.stateStarted, + ]; + + for (final state in states) { + final progress = mapConsultationProgress( + state: state, + hasPersonalDiet: true, + ); + + expect( + progress.dietStatus, + PersonalDietStatus.proposed, + reason: 'state=${state.name}', + ); + } + }); + }); +} diff --git a/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit_test.dart b/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit_test.dart index 82df287b..f1e5cfc3 100644 --- a/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit_test.dart +++ b/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit_test.dart @@ -216,6 +216,8 @@ void main() { consultationId: consultationId, dietId: dietId, )).called(1); + verify(mockPersonalDietSuggestionRepository.getConsultationWithPersonalDiets()).called(2); + verify(mockDietRepository.getDiet(dietId)).called(2); }); test('reattachDietToConsultation - успешное переприкрепление диеты', () async { @@ -265,6 +267,8 @@ void main() { consultationId: consultationId, dietId: dietId, )).called(1); + verify(mockPersonalDietSuggestionRepository.getConsultationWithPersonalDiets()).called(2); + verify(mockDietRepository.getDiet(dietId)).called(2); }); }); } diff --git a/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart b/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart index 55b184b1..1f6fb0e1 100644 --- a/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart +++ b/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart @@ -231,6 +231,54 @@ void main() { }); }); + testWidgets('shows success bottom sheet after successful diet reattachment', (tester) async { + await mockNetworkImagesFor(() async { + final cubit = MockPersonalDietSuggestionCubit(); + final consultationWithDiet = ConsultationModel( + id: 1, + chosenPlanState: ChosenPlanStateEnum.stateInProgress, + planTitle: 'Test Plan', + userName: 'Test User', + userSlug: 'test-user', + consultantPayment: 1000.0, + duration: 7, + currentDiaryDay: 3, + userProfilePhotoUrl: '/avatar.jpg', + consultationOptionPrice: 500, + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 8), + consultantId: 1, + diaries: [], + personalDietId: 1, + ); + + when(cubit.state).thenReturn(PersonalDietSuggestionState()); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.reattachDietToConsultation(consultationId: 1)).thenAnswer((_) async => true); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(ConsultationWidget(consultationModel: consultationWithDiet, dietName: 'Test Diet')), + ), + ); + await tester.pumpAndSettle(); + + final changeButton = find.text(S.of(tester.element(find.byType(ConsultationWidget))).changeDiet); + await tester.tap(changeButton); + await tester.pumpAndSettle(); + + final offerButton = find.text(S.of(tester.element(find.byType(ConsultationWidget))).toOffer); + await tester.tap(offerButton); + await tester.pumpAndSettle(); + + expect(find.text('«Test Diet» предложена пользователю Test User'), findsOneWidget); + verify(cubit.reattachDietToConsultation(consultationId: 1)).called(1); + verifyNever(cubit.attachDietToConsultation(consultationId: 1)); + }); + }); + testWidgets('shows failure bottom sheet after failed diet attachment', (tester) async { await mockNetworkImagesFor(() async { final cubit = MockPersonalDietSuggestionCubit(); @@ -279,7 +327,7 @@ void main() { expect(find.text('Test User'), findsOneWidget); expect(find.text('Test Plan'), findsOneWidget); - expect(find.text('Старт: Test User'), findsOneWidget); + expect(find.text('Старт: 01.01.2025'), findsOneWidget); }); }); @@ -318,7 +366,7 @@ void main() { expect(find.text('Test User'), findsOneWidget); expect(find.text('Test Plan'), findsOneWidget); - expect(find.text('Старт: Test User'), findsNothing); + expect(find.text('Старт: 01.01.2025'), findsNothing); }); }); }); diff --git a/test/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository_test.dart b/test/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository_test.dart index 76b292aa..7d5e26ec 100644 --- a/test/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository_test.dart +++ b/test/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository_test.dart @@ -68,41 +68,33 @@ void main() { test('attachPersonalDiet - успешное прикрепление диеты', () async { const consultationId = 1; const dietId = 2; + final body = {'diet_id': dietId}; - when(mockService.attachPersonalDiet( - consultationId: consultationId, - dietId: dietId, - )).thenAnswer((_) async => {}); + when(mockService.attachPersonalDiet(consultationId, body)) + .thenAnswer((_) async => {}); await repository.attachPersonalDiet( consultationId: consultationId, dietId: dietId, ); - verify(mockService.attachPersonalDiet( - consultationId: consultationId, - dietId: dietId, - )).called(1); + verify(mockService.attachPersonalDiet(consultationId, body)).called(1); }); test('reattachPersonalDiet - успешное переприкрепление диеты', () async { const consultationId = 1; const dietId = 2; + final body = {'diet_id': dietId}; - when(mockService.reattachPersonalDiet( - consultationId: consultationId, - dietId: dietId, - )).thenAnswer((_) async => {}); + when(mockService.reattachPersonalDiet(consultationId, body)) + .thenAnswer((_) async => {}); await repository.reattachPersonalDiet( consultationId: consultationId, dietId: dietId, ); - verify(mockService.reattachPersonalDiet( - consultationId: consultationId, - dietId: dietId, - )).called(1); + verify(mockService.reattachPersonalDiet(consultationId, body)).called(1); }); }); } diff --git a/test/features/personal_diet_suggestion/services/personal_diet_suggestion_service_test.dart b/test/features/personal_diet_suggestion/services/personal_diet_suggestion_service_test.dart index ef33e11f..b18fde38 100644 --- a/test/features/personal_diet_suggestion/services/personal_diet_suggestion_service_test.dart +++ b/test/features/personal_diet_suggestion/services/personal_diet_suggestion_service_test.dart @@ -15,55 +15,48 @@ void main() { setUp(() { mockDio = MockDio(); + when(mockDio.options).thenReturn(BaseOptions(baseUrl: 'http://localhost')); service = PersonalDietSuggestionService(mockDio); }); test('getConsultationWithPersonalDiets - успешное получение консультаций', () async { final mockData = { 'data': [], - 'links': {}, - 'meta': { - 'current_page': 1, - 'last_page': 1, - 'total': 0, - 'per_page': 10, - }, }; + late RequestOptions capturedRequest; - when(mockDio.get( - '/api/consultations', - queryParameters: anyNamed('queryParameters'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/api/consultations'), - statusCode: 200, - data: mockData, - )); + when(mockDio.fetch>(any)).thenAnswer((invocation) async { + capturedRequest = invocation.positionalArguments.first as RequestOptions; + return Response>( + requestOptions: capturedRequest, + statusCode: 200, + data: mockData, + ); + }); final result = await service.getConsultationWithPersonalDiets(); expect(result, isA()); - verify(mockDio.get( - '/api/consultations', - queryParameters: { - 'filters[plan][has_personal_diet][\$personal_diet]': '1', - 'filters[last_state][\$notIn][]': [ - 'done', - 'charge_was_collected', - 'complained', - 'last_day_finished', - 'canceled_by_the_initiator', - 'inited' - ], - 'sort[]': 'start_date:desc', - }, - )).called(1); + expect(capturedRequest.path, '/api/consultations'); + expect(capturedRequest.method, 'GET'); + expect(capturedRequest.queryParameters, { + r'filters[plan][has_personal_diet][$personal_diet]': '1', + r'filters[last_state][$notIn][]': [ + 'done', + 'charge_was_collected', + 'complained', + 'last_day_finished', + 'last_day_started', + 'canceled_by_the_initiator', + 'inited', + ], + r'sort[]': ['start_date:desc'], + }); + verify(mockDio.fetch>(any)).called(1); }); test('getConsultationWithPersonalDiets - пробрасывает ошибку', () async { - when(mockDio.get( - any, - queryParameters: anyNamed('queryParameters'), - )).thenThrow(DioException( + when(mockDio.fetch>(any)).thenThrow(DioException( requestOptions: RequestOptions(path: '/api/consultations'), )); @@ -75,74 +68,70 @@ void main() { test('attachPersonalDiet - успешное прикрепление диеты', () async { const consultationId = 1; - const dietId = 2; + final body = {'diet_id': 2}; + late RequestOptions capturedRequest; - when(mockDio.post( - '/api/chosen_plans/$consultationId/attach_diet', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/attach_diet'), - statusCode: 200, - )); + when(mockDio.fetch(any)).thenAnswer((invocation) async { + capturedRequest = invocation.positionalArguments.first as RequestOptions; + return Response( + requestOptions: capturedRequest, + statusCode: 200, + ); + }); - await service.attachPersonalDiet(consultationId: consultationId, dietId: dietId); + await service.attachPersonalDiet(consultationId, body); - verify(mockDio.post( - '/api/chosen_plans/$consultationId/attach_diet', - data: {'diet_id': dietId}, - )).called(1); + expect(capturedRequest.path, '/api/chosen_plans/$consultationId/attach_diet'); + expect(capturedRequest.method, 'POST'); + expect(capturedRequest.data, body); + verify(mockDio.fetch(any)).called(1); }); test('attachPersonalDiet - пробрасывает ошибку', () async { const consultationId = 1; - const dietId = 2; + final body = {'diet_id': 2}; - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenThrow(DioException( + when(mockDio.fetch(any)).thenThrow(DioException( requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/attach_diet'), )); expect( - () => service.attachPersonalDiet(consultationId: consultationId, dietId: dietId), + () => service.attachPersonalDiet(consultationId, body), throwsA(isA()), ); }); test('reattachPersonalDiet - успешное повторное прикрепление диеты', () async { const consultationId = 1; - const dietId = 3; + final body = {'diet_id': 3}; + late RequestOptions capturedRequest; - when(mockDio.post( - '/api/chosen_plans/$consultationId/re_attach_diet', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/re_attach_diet'), - statusCode: 200, - )); + when(mockDio.fetch(any)).thenAnswer((invocation) async { + capturedRequest = invocation.positionalArguments.first as RequestOptions; + return Response( + requestOptions: capturedRequest, + statusCode: 200, + ); + }); - await service.reattachPersonalDiet(consultationId: consultationId, dietId: dietId); + await service.reattachPersonalDiet(consultationId, body); - verify(mockDio.post( - '/api/chosen_plans/$consultationId/re_attach_diet', - data: {'diet_id': dietId}, - )).called(1); + expect(capturedRequest.path, '/api/chosen_plans/$consultationId/re_attach_diet'); + expect(capturedRequest.method, 'POST'); + expect(capturedRequest.data, body); + verify(mockDio.fetch(any)).called(1); }); test('reattachPersonalDiet - пробрасывает ошибку', () async { const consultationId = 1; - const dietId = 3; + final body = {'diet_id': 3}; - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenThrow(DioException( + when(mockDio.fetch(any)).thenThrow(DioException( requestOptions: RequestOptions(path: '/api/chosen_plans/$consultationId/re_attach_diet'), )); expect( - () => service.reattachPersonalDiet(consultationId: consultationId, dietId: dietId), + () => service.reattachPersonalDiet(consultationId, body), throwsA(isA()), ); });