From bd1155233f2bc6e127922a9d1fabd9c2f302063a Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Tue, 3 Nov 2026 14:45:44 +0300 Subject: [PATCH 1/6] 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()), ); }); From 16084ee94c289fa301b17fc69f41876581ed024c Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Tue, 3 Nov 2026 17:40:54 +0300 Subject: [PATCH 2/6] fix(chat): guard diet duration and keep progress plank - show diet duration hint and plan duration in suggestion flow - disable diet CTA on duration mismatch with grey visual state - keep progress plank visible for personal_diet_setted - center step labels under their circles --- .../widgets/consultation_progress_plank.dart | 74 ++++++----- .../utils/consultation_progress_mapper.dart | 2 +- .../cubit/personal_diet_suggestion_cubit.dart | 6 +- .../cubit/personal_diet_suggestion_state.dart | 4 +- .../presentation/consultation_widget.dart | 57 ++++++++- .../personal_diet_suggestion_screen.dart | 120 +++++++++++++----- lib/generated/intl/messages_en.dart | 59 +++++---- lib/generated/intl/messages_ru.dart | 23 +++- lib/generated/l10n.dart | 45 +++++++ lib/l10n/intl_en.arb | 26 +++- lib/l10n/intl_ru.arb | 26 +++- .../chat_consultation_header_test.dart | 10 +- .../consultation_progress_plank_test.dart | 30 ++++- .../consultation_progress_mapper_test.dart | 6 +- .../personal_diet_suggestion_cubit_test.dart | 3 + .../personal_diet_suggestion_state_test.dart | 6 +- .../consultation_widget_test.dart | 65 ++++++++++ ...nal_diet_suggestion_screen_smoke_test.dart | 27 ++++ 18 files changed, 483 insertions(+), 106 deletions(-) diff --git a/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart b/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart index bfc71d6d..0ad10a62 100644 --- a/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart +++ b/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart @@ -22,47 +22,58 @@ class ConsultationProgressPlank extends StatelessWidget { return const SizedBox.shrink(); } - return Column( + return Row( + crossAxisAlignment: CrossAxisAlignment.start, 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, - ), + for (var index = 0; index < _steps.length; index++) + Expanded( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Container( + height: 2, + color: index == 0 + ? Colors.transparent + : progress.isStepCompleted(_steps[index - 1]) + ? appTheme.greenA700 + : appTheme.lightGray00, + ), + ), + _ProgressStepCircle( + key: ValueKey('consultation_step_circle_$index'), + index: index, + isCompleted: progress.isStepCompleted(_steps[index]), + ), + Expanded( + child: Container( + height: 2, + color: index == _steps.length - 1 + ? Colors.transparent + : 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), + const SizedBox(height: 6), + Text( + _stepLabel(S.of(context), _steps[index]), + key: ValueKey('consultation_step_label_$index'), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyles.textFS10FW500.copyWith( - color: progress.isStepCompleted(step) + color: progress.isStepCompleted(_steps[index]) ? appTheme.black : appTheme.gray, ), ), - ), - ], - ), + ], + ), + ), ], ); } @@ -83,6 +94,7 @@ class ConsultationProgressPlank extends StatelessWidget { class _ProgressStepCircle extends StatelessWidget { const _ProgressStepCircle({ + super.key, required this.index, required this.isCompleted, }); diff --git a/lib/features/consultations/utils/consultation_progress_mapper.dart b/lib/features/consultations/utils/consultation_progress_mapper.dart index c37f6120..98b89d0c 100644 --- a/lib/features/consultations/utils/consultation_progress_mapper.dart +++ b/lib/features/consultations/utils/consultation_progress_mapper.dart @@ -52,7 +52,6 @@ bool _showProgressPlank(ChosenPlanStateEnum state) { switch (state) { case ChosenPlanStateEnum.stateInited: case ChosenPlanStateEnum.stateConfirmedByConsultant: - case ChosenPlanStateEnum.personalDietSet: case ChosenPlanStateEnum.stateChargeWasCollected: case ChosenPlanStateEnum.stateCanceledByTheInitiator: case ChosenPlanStateEnum.statePaymentFailed: @@ -61,6 +60,7 @@ bool _showProgressPlank(ChosenPlanStateEnum state) { case ChosenPlanStateEnum.stateStartDateHasBeenSet: case ChosenPlanStateEnum.statePendingPayment: case ChosenPlanStateEnum.statePaid: + case ChosenPlanStateEnum.personalDietSet: case ChosenPlanStateEnum.stateStarted: case ChosenPlanStateEnum.stateInProgress: case ChosenPlanStateEnum.stateLastDayStarted: 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 fb15fcea..1d8f150e 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 @@ -19,7 +19,11 @@ class PersonalDietSuggestionCubit extends Cubit { } final diet = await dietRepository.getDiet(dietId); if (!isClosed) { - emit(state.copyWith(isLoading: false, dietName: diet.name)); + emit(state.copyWith( + isLoading: false, + dietName: diet.name, + dietDuration: diet.duration, + )); } } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart b/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart index da927703..0270dc4d 100644 --- a/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart +++ b/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart @@ -9,8 +9,8 @@ abstract class PersonalDietSuggestionState with _$PersonalDietSuggestionState { @Default([]) List consultationsWithDiet, @Default([]) List consultationsWithoutDiet, @Default('') String dietName, + @Default(0) int dietDuration, @Default(false) bool isLoading, @Default(null) String? error, }) = _PersonalDietSuggestionState; - -} \ No newline at end of file +} diff --git a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart index 2e74e582..a6846e57 100644 --- a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart +++ b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/localization_utils.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; @@ -20,9 +21,15 @@ class ConsultationWidget extends StatelessWidget { super.key, required this.consultationModel, required this.dietName, + this.dietDuration = 0, }); final ConsultationModel consultationModel; final String dietName; + final int dietDuration; + + bool get _isDurationMismatch => + dietDuration > 0 && consultationModel.duration != dietDuration; + @override Widget build(BuildContext context) { final s = S.of(context); @@ -64,6 +71,15 @@ class ConsultationWidget extends StatelessWidget { color: const Color(0xFF88949F), ), ), + Text( + s.plan_duration_label( + consultationModel.duration, + getDayForm(consultationModel.duration, s), + ), + style: TextStyles.textMMedium.copyWith( + color: const Color(0xFF88949F), + ), + ), Text( s.start_date_label( DateFormat('dd.MM.yyyy').format( @@ -108,15 +124,47 @@ class ConsultationWidget extends StatelessWidget { color: const Color(0xFF88949F), ), ), + Text( + s.plan_duration_label( + consultationModel.duration, + getDayForm(consultationModel.duration, s), + ), + style: TextStyles.textMMedium.copyWith( + color: const Color(0xFF88949F), + ), + ), ], ), ], ), const SizedBox(height: 12), + if (_isDurationMismatch) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, size: 18, color: appTheme.red), + const SizedBox(width: 8), + Expanded( + child: Text( + s.personal_diet_duration_mismatch( + dietDuration, + getDayForm(dietDuration, s), + consultationModel.duration, + getDayForm(consultationModel.duration, s), + ), + style: TextStyles.textSMedium.copyWith(color: appTheme.red), + ), + ), + ], + ), + ), if (consultationModel.personalDietId != null) CustomOutlinedButton( text: s.changeDiet, height: 52, + isDisabled: _isDurationMismatch, buttonTextStyle: TextStyles.textMSemibold.copyWith( color: appTheme.gray, ), @@ -171,12 +219,17 @@ class ConsultationWidget extends StatelessWidget { CustomOutlinedButton( text: s.suggestDiet, height: 52, + isDisabled: _isDurationMismatch, buttonTextStyle: TextStyles.textMSemibold.copyWith( - color: appTheme.whiteA700, + color: _isDurationMismatch ? appTheme.gray : appTheme.whiteA700, ), buttonStyle: ButtonStyle( side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + backgroundColor: WidgetStatePropertyAll( + _isDurationMismatch + ? appTheme.dividerColor + : appTheme.blueA400, + ), ), onPressed: () async { showBottomSheetWithBlur( diff --git a/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart b/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart index 32398019..0821f978 100644 --- a/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart +++ b/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/localization_utils.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart'; @@ -17,7 +18,9 @@ class PersonalDietSuggestionScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => sl()..getConsultationWithPersonalDiets(dietId), + create: (context) => + sl() + ..getConsultationWithPersonalDiets(dietId), child: const PersonalDietSuggestionScreenContent(), ); } @@ -28,7 +31,10 @@ class PersonalDietSuggestionScreenContent extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - return BlocBuilder( + return BlocBuilder< + PersonalDietSuggestionCubit, + PersonalDietSuggestionState + >( builder: (context, state) { return Scaffold( backgroundColor: appTheme.gray100, @@ -36,45 +42,97 @@ class PersonalDietSuggestionScreenContent extends StatelessWidget { centerTitle: true, title: Text( '${s.dietSuggestion}\n«${state.dietName}»', - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + style: TextStyles.headingXLSemibold.copyWith( + color: appTheme.black, + ), ), ), body: state.isLoading ? const Center(child: Loader()) - : (state.consultationsWithDiet.isEmpty && state.consultationsWithoutDiet.isEmpty) - ? Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Text( - 'Нет активных консультаций с персональной диетой', - style: TextStyles.textMSemibold.copyWith(color: appTheme.gray), - textAlign: TextAlign.center, - ), - ), - ) - : SingleChildScrollView( - child: Column( - children: [ - ...List.generate(state.consultationsWithDiet.length, (index) { - return ConsultationWidget( - consultationModel: state.consultationsWithDiet[index], - dietName: state.dietName, - ); - }), - const SizedBox( - height: 12, + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.dietDuration > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: _PersonalDietDurationHint( + duration: state.dietDuration, ), - ...List.generate(state.consultationsWithoutDiet.length, (index) { + ), + if (state.consultationsWithDiet.isEmpty && + state.consultationsWithoutDiet.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Text( + s.no_active_personal_diet_consultations, + style: TextStyles.textMSemibold.copyWith( + color: appTheme.gray, + ), + textAlign: TextAlign.center, + ), + ) + else ...[ + ...List.generate(state.consultationsWithDiet.length, ( + index, + ) { + return ConsultationWidget( + consultationModel: + state.consultationsWithDiet[index], + dietName: state.dietName, + dietDuration: state.dietDuration, + ); + }), + const SizedBox(height: 12), + ...List.generate( + state.consultationsWithoutDiet.length, + (index) { return ConsultationWidget( - consultationModel: state.consultationsWithoutDiet[index], + consultationModel: + state.consultationsWithoutDiet[index], dietName: state.dietName, + dietDuration: state.dietDuration, ); - }) - ], - ), - ), + }, + ), + ], + ], + ), + ), ); }, ); } } + +class _PersonalDietDurationHint extends StatelessWidget { + const _PersonalDietDurationHint({required this.duration}); + + final int duration; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: appTheme.gray50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: appTheme.dividerColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 20, color: appTheme.blueA400), + const SizedBox(width: 8), + Expanded( + child: Text( + s.personal_diet_duration_hint(duration, getDayForm(duration, s)), + style: TextStyles.textSMedium.copyWith(color: appTheme.gray), + ), + ), + ], + ), + ); + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 2a2f9203..30ef7582 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -22,34 +22,42 @@ class MessageLookup extends MessageLookupByLibrary { static String m0(date) => "Консультант прокомментировал день ${date}"; - static String m1(planName) => "План «${planName}» начался"; + static String m1(duration, dayWord) => + "The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration."; - static String m2(planName, name) => + static String m2(dietDuration, dietDayWord, planDuration, planDayWord) => + "Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}."; + + static String m3(planName) => "План «${planName}» начался"; + + static String m4(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m3(percent, amount) => + static String m5(duration, dayWord) => "Plan for ${duration} ${dayWord}"; + + static String m6(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m4(date) => "Start: ${date}"; + static String m7(date) => "Start: ${date}"; - static String m5(step) => "шаг ${step} из 2"; + static String m8(step) => "шаг ${step} из 2"; - static String m6(planName, name) => + static String m9(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m7(planName) => + static String m10(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m8(name, planName) => + static String m11(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m9(name, planName) => + static String m12(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m10(name, planName) => + static String m13(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m11(planName) => "Вы отменили подписку на план «${planName}»"; + static String m14(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -609,6 +617,10 @@ class MessageLookup extends MessageLookupByLibrary { "nicotine": MessageLookupByLibrary.simpleMessage("Никотин"), "no": MessageLookupByLibrary.simpleMessage("Нет"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), + "no_active_personal_diet_consultations": + MessageLookupByLibrary.simpleMessage( + "No active consultations with a personal diet", + ), "no_default_cards": MessageLookupByLibrary.simpleMessage( "У вас нет привязанных карт. Что бы начать работу, привяжите карту", ), @@ -673,6 +685,8 @@ class MessageLookup extends MessageLookupByLibrary { "personalDietCreating": MessageLookupByLibrary.simpleMessage( "Составление индивидуальной диеты", ), + "personal_diet_duration_hint": m1, + "personal_diet_duration_mismatch": m2, "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( "Personal diet not proposed", ), @@ -698,17 +712,18 @@ class MessageLookup extends MessageLookupByLibrary { "Физическая форма", ), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), - "planStarted": m1, + "planStarted": m3, "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "Если вы не подтвердите, то план через три дня подтвердится автоматически", ), - "planWithUserStarted": m2, + "planWithUserStarted": m4, "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc_example": MessageLookupByLibrary.simpleMessage( "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), + "plan_duration_label": m5, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -745,7 +760,7 @@ class MessageLookup extends MessageLookupByLibrary { "Promo code applied!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), - "promo_discount_hint": m3, + "promo_discount_hint": m6, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), @@ -838,13 +853,13 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m4, + "start_date_label": m7, "statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "step": MessageLookupByLibrary.simpleMessage("Шаг"), "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m5, + "step_widget": m8, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -869,8 +884,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m6, - "tomorrowYourPlanWillStart": m7, + "tommorowPlanWithUserWillStart": m9, + "tomorrowYourPlanWillStart": m10, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -904,7 +919,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m8, + "userCanceldThePlanSubscription": m11, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -912,8 +927,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m9, - "userSubscribedToPlan": m10, + "userPaidThePlan": m12, + "userSubscribedToPlan": m13, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1043,7 +1058,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m11, + "youCanceledSubscriptionToPlan": m14, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 34a668d5..f6788ff2 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -20,10 +20,18 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'ru'; - static String m3(percent, amount) => + static String m1(duration, dayWord) => + "Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью."; + + static String m2(dietDuration, dietDayWord, planDuration, planDayWord) => + "Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}."; + + static String m5(duration, dayWord) => "План на ${duration} ${dayWord}"; + + static String m6(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; - static String m4(date) => "Старт: ${date}"; + static String m7(date) => "Старт: ${date}"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -276,6 +284,10 @@ class MessageLookup extends MessageLookupByLibrary { ), "no": MessageLookupByLibrary.simpleMessage("Нет"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), + "no_active_personal_diet_consultations": + MessageLookupByLibrary.simpleMessage( + "Нет активных консультаций с персональной диетой", + ), "no_installed_banks": MessageLookupByLibrary.simpleMessage( "Нет установленных банков", ), @@ -317,6 +329,8 @@ class MessageLookup extends MessageLookupByLibrary { "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"), + "personal_diet_duration_hint": m1, + "personal_diet_duration_mismatch": m2, "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( "Диета не предложена", ), @@ -338,6 +352,7 @@ class MessageLookup extends MessageLookupByLibrary { "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), + "plan_duration_label": m5, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -365,7 +380,7 @@ class MessageLookup extends MessageLookupByLibrary { "Промокод применён!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), - "promo_discount_hint": m3, + "promo_discount_hint": m6, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), @@ -398,7 +413,7 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m4, + "start_date_label": m7, "suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"), "tell_your_schedule": MessageLookupByLibrary.simpleMessage( "Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 69416eec..6079aad0 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4503,6 +4503,51 @@ class S { args: [date], ); } + + /// `The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.` + String personal_diet_duration_hint(int duration, String dayWord) { + return Intl.message( + 'The diet lasts $duration $dayWord. It can only be offered for a plan with the same duration.', + name: 'personal_diet_duration_hint', + desc: '', + args: [duration, dayWord], + ); + } + + /// `Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.` + String personal_diet_duration_mismatch( + int dietDuration, + String dietDayWord, + int planDuration, + String planDayWord, + ) { + return Intl.message( + 'Not suitable: diet lasts $dietDuration $dietDayWord, plan lasts $planDuration $planDayWord.', + name: 'personal_diet_duration_mismatch', + desc: '', + args: [dietDuration, dietDayWord, planDuration, planDayWord], + ); + } + + /// `Plan for {duration} {dayWord}` + String plan_duration_label(int duration, String dayWord) { + return Intl.message( + 'Plan for $duration $dayWord', + name: 'plan_duration_label', + desc: '', + args: [duration, dayWord], + ); + } + + /// `No active consultations with a personal diet` + String get no_active_personal_diet_consultations { + return Intl.message( + 'No active consultations with a personal diet', + name: 'no_active_personal_diet_consultations', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 910e15e7..e26e2262 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -537,6 +537,30 @@ "placeholders": { "date": {"type": "String"} } - } + }, + "personal_diet_duration_hint": "The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.", + "@personal_diet_duration_hint": { + "placeholders": { + "duration": {"type": "int"}, + "dayWord": {"type": "String"} + } + }, + "personal_diet_duration_mismatch": "Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.", + "@personal_diet_duration_mismatch": { + "placeholders": { + "dietDuration": {"type": "int"}, + "dietDayWord": {"type": "String"}, + "planDuration": {"type": "int"}, + "planDayWord": {"type": "String"} + } + }, + "plan_duration_label": "Plan for {duration} {dayWord}", + "@plan_duration_label": { + "placeholders": { + "duration": {"type": "int"}, + "dayWord": {"type": "String"} + } + }, + "no_active_personal_diet_consultations": "No active consultations with a personal diet" } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 04a2af45..0f161936 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -292,5 +292,29 @@ "placeholders": { "date": {"type": "String"} } - } + }, + "personal_diet_duration_hint": "Диета рассчитана на {duration} {dayWord}. Предложить её можно только для плана с такой же длительностью.", + "@personal_diet_duration_hint": { + "placeholders": { + "duration": {"type": "int"}, + "dayWord": {"type": "String"} + } + }, + "personal_diet_duration_mismatch": "Не подходит: диета на {dietDuration} {dietDayWord}, план на {planDuration} {planDayWord}.", + "@personal_diet_duration_mismatch": { + "placeholders": { + "dietDuration": {"type": "int"}, + "dietDayWord": {"type": "String"}, + "planDuration": {"type": "int"}, + "planDayWord": {"type": "String"} + } + }, + "plan_duration_label": "План на {duration} {dayWord}", + "@plan_duration_label": { + "placeholders": { + "duration": {"type": "int"}, + "dayWord": {"type": "String"} + } + }, + "no_active_personal_diet_consultations": "Нет активных консультаций с персональной диетой" } diff --git a/test/features/chat/presentation/widgets/chat_consultation_header_test.dart b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart index 18d154e4..96c10b05 100644 --- a/test/features/chat/presentation/widgets/chat_consultation_header_test.dart +++ b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart @@ -50,13 +50,13 @@ void main() { }); testWidgets( - 'при showProgressPlank=false и proposed видны заголовок и чип, плашки нет', + 'personal_diet_setted показывает плашку и чип вместе с баннером замены', (tester) async { final progress = mapConsultationProgress( state: ChosenPlanStateEnum.personalDietSet, hasPersonalDiet: true, ); - expect(progress.showProgressPlank, isFalse); + expect(progress.showProgressPlank, isTrue); expect(progress.dietStatus, PersonalDietStatus.proposed); await tester.pumpWidget( @@ -71,11 +71,11 @@ void main() { final s = S.of(tester.element(find.byType(ChatConsultationHeader))); - expect(find.text(s.more_about_order), findsOneWidget); + expect(find.text(s.order_in_progress), findsNWidgets(2)); expect(find.byType(PersonalDietStatusChip), findsOneWidget); expect(find.text(s.personal_diet_proposed), findsOneWidget); - expect(find.byType(ConsultationProgressPlank), findsNothing); - expect(find.byIcon(Icons.check), findsNothing); + expect(find.byType(ConsultationProgressPlank), findsOneWidget); + expect(find.byIcon(Icons.check), findsNWidgets(2)); }, ); }); diff --git a/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart b/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart index a7e81ce1..6d50d483 100644 --- a/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart +++ b/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart @@ -25,7 +25,7 @@ void main() { group('ConsultationProgressPlank', () { testWidgets('не строит шаги при showProgressPlank=false', (tester) async { final progress = mapConsultationProgress( - state: ChosenPlanStateEnum.personalDietSet, + state: ChosenPlanStateEnum.stateConfirmedByConsultant, hasPersonalDiet: true, ); expect(progress.showProgressPlank, isFalse); @@ -67,5 +67,33 @@ void main() { expect(find.text(s.done), findsOneWidget); }, ); + + testWidgets('подписи шагов центрированы под своими кружками', ( + tester, + ) async { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.stateDone, + hasPersonalDiet: false, + ); + + await tester.pumpWidget( + _wrap( + SizedBox( + width: 360, + child: ConsultationProgressPlank(progress: progress), + ), + ), + ); + + for (var index = 0; index < 4; index++) { + final circle = tester.getCenter( + find.byKey(ValueKey('consultation_step_circle_$index')), + ); + final label = tester.getCenter( + find.byKey(ValueKey('consultation_step_label_$index')), + ); + expect(label.dx, closeTo(circle.dx, 0.5)); + } + }); }); } diff --git a/test/features/consultations/utils/consultation_progress_mapper_test.dart b/test/features/consultations/utils/consultation_progress_mapper_test.dart index 09ee87a8..16c0f1c5 100644 --- a/test/features/consultations/utils/consultation_progress_mapper_test.dart +++ b/test/features/consultations/utils/consultation_progress_mapper_test.dart @@ -96,7 +96,7 @@ final Map _expectations = { ), ChosenPlanStateEnum.personalDietSet: ( steps: {_startDate, _paid}, - plank: false, + plank: true, diet: PersonalDietStatus.proposed, phase: ConsultationPhase.active, ), @@ -148,14 +148,14 @@ void main() { }); test( - 'personal_diet_setted не показывает плашку и завершает startDate+paid', + 'personal_diet_setted показывает плашку и завершает startDate+paid', () { final progress = mapConsultationProgress( state: ChosenPlanStateEnum.personalDietSet, hasPersonalDiet: true, ); - expect(progress.showProgressPlank, isFalse); + expect(progress.showProgressPlank, isTrue); expect(progress.completedSteps, unorderedEquals([_startDate, _paid])); }, ); 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 f1e5cfc3..c30b99f3 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 @@ -34,6 +34,7 @@ void main() { expect(cubit.state.consultationsWithDiet, isEmpty); expect(cubit.state.consultationsWithoutDiet, isEmpty); expect(cubit.state.dietName, isEmpty); + expect(cubit.state.dietDuration, 0); expect(cubit.state.isLoading, isFalse); expect(cubit.state.error, isNull); }); @@ -71,6 +72,7 @@ void main() { await cubit.setDietName(dietId); expect(cubit.state.dietName, equals(dietName)); + expect(cubit.state.dietDuration, 30); expect(cubit.state.isLoading, isFalse); verify(mockDietRepository.getDiet(dietId)).called(1); }); @@ -161,6 +163,7 @@ void main() { await cubit.getConsultationWithPersonalDiets(dietId); expect(cubit.state.dietName, equals(dietName)); + expect(cubit.state.dietDuration, 30); expect(cubit.state.consultationsWithDiet.length, 1); expect(cubit.state.consultationsWithoutDiet.length, 1); expect(cubit.state.isLoading, isFalse); diff --git a/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state_test.dart b/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state_test.dart index 256bb75b..23be2f78 100644 --- a/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state_test.dart +++ b/test/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state_test.dart @@ -9,6 +9,7 @@ void main() { expect(state.consultationsWithDiet, isEmpty); expect(state.consultationsWithoutDiet, isEmpty); expect(state.dietName, ''); + expect(state.dietDuration, 0); expect(state.isLoading, isFalse); expect(state.error, isNull); }); @@ -16,6 +17,7 @@ void main() { test('creates instance with custom values', () { final state = PersonalDietSuggestionState( dietName: 'Test Diet', + dietDuration: 14, isLoading: true, error: 'Test error', ); @@ -23,6 +25,7 @@ void main() { expect(state.consultationsWithDiet, isEmpty); expect(state.consultationsWithoutDiet, isEmpty); expect(state.dietName, 'Test Diet'); + expect(state.dietDuration, 14); expect(state.isLoading, isTrue); expect(state.error, 'Test error'); }); @@ -31,14 +34,15 @@ void main() { final original = PersonalDietSuggestionState(); final updated = original.copyWith( dietName: 'New Diet', + dietDuration: 7, isLoading: true, ); expect(updated.dietName, 'New Diet'); + expect(updated.dietDuration, 7); expect(updated.isLoading, isTrue); expect(updated.consultationsWithDiet, original.consultationsWithDiet); expect(updated.error, original.error); }); }); } - 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 1f6fb0e1..6aef200f 100644 --- a/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart +++ b/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/localization_utils.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; @@ -369,5 +370,69 @@ void main() { expect(find.text('Старт: 01.01.2025'), findsNothing); }); }); + + testWidgets('disables CTA when plan duration differs from diet duration', (tester) async { + await mockNetworkImagesFor(() async { + final cubit = MockPersonalDietSuggestionCubit(); + final consultation = ConsultationModel( + id: 1, + chosenPlanState: ChosenPlanStateEnum.stateInProgress, + planTitle: 'Test Plan', + userName: 'Test User', + userSlug: 'test-user', + consultantPayment: 1000.0, + duration: 14, + currentDiaryDay: 3, + userProfilePhotoUrl: '/avatar.jpg', + consultationOptionPrice: 500, + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 15), + consultantId: 1, + diaries: [], + personalDietId: null, + ); + + when(cubit.state).thenReturn(PersonalDietSuggestionState()); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap( + ConsultationWidget( + consultationModel: consultation, + dietName: 'Test Diet', + dietDuration: 7, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final s = S.of(tester.element(find.byType(ConsultationWidget))); + expect( + find.text( + s.personal_diet_duration_mismatch( + 7, + getDayForm(7, s), + 14, + getDayForm(14, s), + ), + ), + findsOneWidget, + ); + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNull); + expect( + button.style?.backgroundColor?.resolve({WidgetState.disabled}), + appTheme.dividerColor, + ); + expect( + tester.widget(find.text(s.suggestDiet)).style?.color, + appTheme.gray, + ); + }); + }); }); } diff --git a/test/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen_smoke_test.dart b/test/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen_smoke_test.dart index b74938ae..624d85ac 100644 --- a/test/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen_smoke_test.dart +++ b/test/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen_smoke_test.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/localization_utils.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart'; @@ -84,5 +85,31 @@ void main() { expect(find.textContaining('Test Diet Name'), findsOneWidget); }); + + testWidgets('shows diet duration hint and empty state', (tester) async { + final cubit = MockPersonalDietSuggestionCubit(); + const dietDuration = 7; + final state = PersonalDietSuggestionState( + isLoading: false, + dietName: 'Test Diet', + dietDuration: dietDuration, + consultationsWithDiet: [], + consultationsWithoutDiet: [], + ); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await tester.pumpWidget(wrap(const PersonalDietSuggestionScreenContent(), cubit)); + await tester.pump(); + + final s = S.of(tester.element(find.byType(PersonalDietSuggestionScreenContent))); + expect( + find.text(s.personal_diet_duration_hint(dietDuration, getDayForm(dietDuration, s))), + findsOneWidget, + ); + expect(find.text(s.no_active_personal_diet_consultations), findsOneWidget); + }); }); } From 57536aba063331eca46462ae741fd97db4db7ac1 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Thu, 5 Nov 2026 18:28:16 +0300 Subject: [PATCH 3/6] fix(personal-diet): open client chat after diet assignment - navigate to the client chat after a successful attach or reattach - reset the stack so back returns to the chat list - keep the diet screen on missing chat id and cover the flow with tests --- .../presentation/consultation_widget.dart | 153 +++++------------- .../consultation_widget_test.dart | 79 ++++++++- 2 files changed, 113 insertions(+), 119 deletions(-) diff --git a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart index a6846e57..9244f588 100644 --- a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart +++ b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/localization_utils.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; @@ -181,35 +182,8 @@ class ConsultationWidget extends StatelessWidget { userName: consultationModel.userName, dietName: dietName, onTap: () async { - final result = await context - .read() - .reattachDietToConsultation( - consultationId: consultationModel.id, - ); AppRouter.navigatorKey.currentContext?.pop(); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - userRootNavigator: true, - context: context, - height: null, - child: SuggestionSuccessBottomSheet( - userName: consultationModel.userName, - dietName: dietName, - ), - ); - return; - } - if (!context.mounted) return; - showBottomSheetWithBlur( - userRootNavigator: true, - context: context, - height: null, - child: SuggestionFailureBottomSheet( - userName: consultationModel.userName, - dietName: dietName, - ), - ); + await _attachDiet(context, reattach: true); }, ), ); @@ -241,34 +215,7 @@ class ConsultationWidget extends StatelessWidget { dietName: dietName, onTap: () async { AppRouter.navigatorKey.currentContext?.pop(); - final result = await context - .read() - .attachDietToConsultation( - consultationId: consultationModel.id, - ); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - userRootNavigator: true, - context: context, - height: null, - child: SuggestionSuccessBottomSheet( - userName: consultationModel.userName, - dietName: dietName, - ), - ); - return; - } - if (!context.mounted) return; - showBottomSheetWithBlur( - userRootNavigator: true, - context: context, - height: null, - child: SuggestionFailureBottomSheet( - userName: consultationModel.userName, - dietName: dietName, - ), - ); + await _attachDiet(context, reattach: false); }, ), ); @@ -278,6 +225,41 @@ class ConsultationWidget extends StatelessWidget { ), ); } + + Future _attachDiet( + BuildContext context, { + required bool reattach, + }) async { + final router = GoRouter.of(context); + final chatId = consultationModel.chatId; + final noChatMessage = S.of(context).msg_something_went_wrong; + final cubit = context.read(); + final result = reattach + ? await cubit.reattachDietToConsultation( + consultationId: consultationModel.id, + ) + : await cubit.attachDietToConsultation( + consultationId: consultationModel.id, + ); + if (!result) { + if (!context.mounted) return; + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + height: null, + child: SuggestionFailureBottomSheet( + userName: consultationModel.userName, + dietName: dietName, + ), + ); + return; + } + if (chatId == null || chatId.isEmpty) { + showErrorToast(noChatMessage); + return; + } + router.goNamed('chatScreen', queryParameters: {'uuid': chatId}); + } } class SuggestDietBottomSheet extends StatefulWidget { @@ -336,65 +318,6 @@ class _SuggestDietBottomSheet extends State { } } -class SuggestionSuccessBottomSheet extends StatefulWidget { - const SuggestionSuccessBottomSheet({ - super.key, - required this.userName, - required this.dietName, - }); - final String userName; - final String dietName; - - @override - State createState() => - _SuggestionSuccessBottomSheet(); -} - -class _SuggestionSuccessBottomSheet - extends State { - @override - Widget build(BuildContext context) { - final s = S.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - const BottomSheetHandlerBar(), - const SizedBox(height: 20), - Text( - 'Предложение диеты', - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - const SizedBox(height: 16), - Text( - '«${widget.dietName}» предложена пользователю ${widget.userName}', - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - CustomOutlinedButton( - text: s.ok, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith( - color: appTheme.whiteA700, - ), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), - onPressed: () async { - AppRouter.navigatorKey.currentContext?.pop(); - }, - ), - const SizedBox(height: 40), - ], - ), - ); - } -} - class SuggestionFailureBottomSheet extends StatefulWidget { const SuggestionFailureBottomSheet({ super.key, 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 6aef200f..11b8d568 100644 --- a/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart +++ b/test/features/personal_diet_suggestion/presentation/consultation_widget_test.dart @@ -35,6 +35,7 @@ void main() { final mockConsultation = ConsultationModel( id: 1, + chatId: 'chat-1', chosenPlanState: ChosenPlanStateEnum.stateInProgress, planTitle: 'Test Plan', userName: 'Test User', @@ -69,6 +70,32 @@ void main() { path: '/personal-diet-suggestion', builder: (context, state) => const Scaffold(body: Text('Personal Diet')), ), + GoRoute( + path: '/chat-select-screen', + name: 'chatSelectScreen', + builder: (context, state) => const Scaffold(body: Text('Chats')), + ), + GoRoute( + path: '/chat-screen', + name: 'chatScreen', + builder: (context, state) { + final uuid = state.uri.queryParameters['uuid']; + return Scaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () { + if (context.canPop()) { + GoRouter.of(context).pop(); + return; + } + GoRouter.of(context).go('/chat-select-screen'); + }, + ), + ), + body: Text('Chat $uuid'), + ); + }, + ), ], ), ); @@ -202,7 +229,7 @@ void main() { }); }); - testWidgets('shows success bottom sheet after successful diet attachment', (tester) async { + testWidgets('opens chat after successful diet attachment', (tester) async { await mockNetworkImagesFor(() async { final cubit = MockPersonalDietSuggestionCubit(); @@ -227,16 +254,22 @@ void main() { await tester.tap(offerButton); await tester.pumpAndSettle(); - expect(find.text('«Test Diet» предложена пользователю Test User'), findsOneWidget); + expect(find.text('Chat chat-1'), findsOneWidget); verify(cubit.attachDietToConsultation(consultationId: 1)).called(1); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Chats'), findsOneWidget); }); }); - testWidgets('shows success bottom sheet after successful diet reattachment', (tester) async { + testWidgets('opens chat after successful diet reattachment', (tester) async { await mockNetworkImagesFor(() async { final cubit = MockPersonalDietSuggestionCubit(); final consultationWithDiet = ConsultationModel( id: 1, + chatId: 'chat-2', chosenPlanState: ChosenPlanStateEnum.stateInProgress, planTitle: 'Test Plan', userName: 'Test User', @@ -274,9 +307,47 @@ void main() { await tester.tap(offerButton); await tester.pumpAndSettle(); - expect(find.text('«Test Diet» предложена пользователю Test User'), findsOneWidget); + expect(find.text('Chat chat-2'), findsOneWidget); verify(cubit.reattachDietToConsultation(consultationId: 1)).called(1); verifyNever(cubit.attachDietToConsultation(consultationId: 1)); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Chats'), findsOneWidget); + }); + }); + + testWidgets('stays on screen when chat id is missing after success', (tester) async { + await mockNetworkImagesFor(() async { + final cubit = MockPersonalDietSuggestionCubit(); + + when(cubit.state).thenReturn(PersonalDietSuggestionState()); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.attachDietToConsultation(consultationId: 1)).thenAnswer((_) async => true); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap( + ConsultationWidget( + consultationModel: mockConsultation.copyWith(chatId: null), + dietName: 'Test Diet', + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Предложить диету')); + await tester.pumpAndSettle(); + await tester.tap(find.text(S.of(tester.element(find.byType(ConsultationWidget))).toOffer)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Chat '), findsNothing); + expect(find.text('Test User'), findsOneWidget); + verify(cubit.attachDietToConsultation(consultationId: 1)).called(1); }); }); From 0241265b3cf333fff3e68ba36bcab69d8aee4e9f Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 6 Nov 2026 13:33:44 +0300 Subject: [PATCH 4/6] fix: align diary diet row and hide terminal cancel - show only the personal diet name in the diary and keep the plan day chip - hide consultation cancellation for terminal and non-cancellable states - enable the refund form only for paid, started and in-progress plans - cover diary rows and order info cancel visibility with tests --- .../widgets/chosen_diet_widget.dart | 20 +-------- .../presentation/info_by_order_screen.dart | 4 +- .../plans/enums/chosen_plan_state_enum.dart | 26 ++++++++++++ .../chosen_diet_widget_content_test.dart | 11 ++--- .../diary_chosen_widgets_smoke_test.dart | 11 +++++ .../info_by_order_screen_test.dart | 21 +++++++++- .../enums/chosen_plan_state_enum_test.dart | 41 +++++++++++++++++++ 7 files changed, 105 insertions(+), 29 deletions(-) diff --git a/lib/features/diary/presentation/widgets/chosen_diet_widget.dart b/lib/features/diary/presentation/widgets/chosen_diet_widget.dart index 622e0e5e..19411307 100644 --- a/lib/features/diary/presentation/widgets/chosen_diet_widget.dart +++ b/lib/features/diary/presentation/widgets/chosen_diet_widget.dart @@ -4,7 +4,6 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; -import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -19,9 +18,7 @@ class ChosenDietWidget extends StatelessWidget { if (state is DiaryStateLoaded) { if (state.diary.typeOfDiary.hasChosenDiet) { return ChosenDietWidgetContent( - currentDietDay: state.diary.dayOfChosenDiet ?? 0, dietName: state.diary.diet?.name ?? '', - dietDuration: state.diary.diet?.duration ?? 0, dietId: state.diary.diet?.id ?? 0, ); } @@ -33,9 +30,7 @@ class ChosenDietWidget extends StatelessWidget { const CustomDivider(), const SizedBox(height: 16), ChosenDietWidgetContent( - currentDietDay: state.diary.dayOfChosenDiet ?? 0, dietName: state.diary.diet?.name ?? '', - dietDuration: state.diary.diet?.duration ?? 0, dietId: state.diary.diet?.id ?? 0, ), ], @@ -50,15 +45,11 @@ class ChosenDietWidget extends StatelessWidget { } class ChosenDietWidgetContent extends StatelessWidget { - const ChosenDietWidgetContent({super.key, required this.currentDietDay, required this.dietName, required this.dietDuration, required this.dietId}); - final int currentDietDay; + const ChosenDietWidgetContent({super.key, required this.dietName, required this.dietId}); final String dietName; - final int dietDuration; final int dietId; @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - final s = S.of(context); return GestureDetector( onTap: () { GoRouter.of(context).push(RouteConstants.aboutDietScreen, extra: {'id': dietId, 'navigateToComments': false}); @@ -67,19 +58,12 @@ class ChosenDietWidgetContent extends StatelessWidget { children: [ Row( children: [ - SizedBox( - width: size.width * 0.4, + Expanded( child: Text( dietName, style: TextStyles.textLMedium.copyWith(color: appTheme.black, decoration: TextDecoration.underline), ), ), - const Spacer(), - Container( - decoration: BoxDecoration(color: appTheme.dividerColor, borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), - child: Text('${s.day} $currentDietDay из $dietDuration', style: TextStyles.textMSemibold.copyWith(color: appTheme.gray)), - ), ], ), const SizedBox(height: 16), diff --git a/lib/features/order_info/presentation/info_by_order_screen.dart b/lib/features/order_info/presentation/info_by_order_screen.dart index c37913f5..40a5f8c2 100644 --- a/lib/features/order_info/presentation/info_by_order_screen.dart +++ b/lib/features/order_info/presentation/info_by_order_screen.dart @@ -164,7 +164,7 @@ class _InfoByOrderScreenContentState extends State { const SizedBox(height: 8), PlanState(state: plan.chosenPlanState, hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false), const SizedBox(height: 20), - if (widget.successfulPayment == null) + if (widget.successfulPayment == null && plan.chosenPlanState.canShowCancelAction) CustomOutlinedButton( text: s.cancelConsultation, height: 52, @@ -173,7 +173,7 @@ class _InfoByOrderScreenContentState extends State { onPressed: () async { showModalBottomSheet( context: context, - builder: (context) => CancelPlanBottomSheet(enableForm: plan.chosenPlanState.isStateAfterPaidState(), planId: plan.id), + builder: (context) => CancelPlanBottomSheet(enableForm: plan.chosenPlanState.isCancelAfterPaidState, planId: plan.id), ); }, ), diff --git a/lib/features/plans/enums/chosen_plan_state_enum.dart b/lib/features/plans/enums/chosen_plan_state_enum.dart index 2e867f38..fad8736c 100644 --- a/lib/features/plans/enums/chosen_plan_state_enum.dart +++ b/lib/features/plans/enums/chosen_plan_state_enum.dart @@ -62,6 +62,32 @@ enum ChosenPlanStateEnum { } } + bool get canShowCancelAction { + switch (this) { + case ChosenPlanStateEnum.stateInited: + case ChosenPlanStateEnum.stateInCoordination: + case ChosenPlanStateEnum.stateStartDateHasBeenSet: + case ChosenPlanStateEnum.statePendingPayment: + case ChosenPlanStateEnum.statePaid: + case ChosenPlanStateEnum.stateStarted: + case ChosenPlanStateEnum.stateInProgress: + return true; + default: + return false; + } + } + + bool get isCancelAfterPaidState { + switch (this) { + case ChosenPlanStateEnum.statePaid: + case ChosenPlanStateEnum.stateStarted: + case ChosenPlanStateEnum.stateInProgress: + return true; + default: + return false; + } + } + static List getActiveStates() { return [ ChosenPlanStateEnum.statePaid, diff --git a/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart b/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart index 0ef9081a..5481b27f 100644 --- a/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart +++ b/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart @@ -27,9 +27,7 @@ void main() { await tester.pumpWidget( createTestWidget( const ChosenDietWidgetContent( - currentDietDay: 5, dietName: 'Test Diet', - dietDuration: 14, dietId: 1, ), ), @@ -38,15 +36,14 @@ void main() { expect(find.byType(ChosenDietWidgetContent), findsOneWidget); expect(find.text('Test Diet'), findsOneWidget); + expect(find.textContaining('из 14'), findsNothing); }); - testWidgets('displays current day and duration', (tester) async { + testWidgets('does not display current day and duration', (tester) async { await tester.pumpWidget( createTestWidget( const ChosenDietWidgetContent( - currentDietDay: 7, dietName: 'Test Diet', - dietDuration: 21, dietId: 1, ), ), @@ -54,15 +51,14 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ChosenDietWidgetContent), findsOneWidget); + expect(find.textContaining('из 21'), findsNothing); }); testWidgets('displays diet name with underline', (tester) async { await tester.pumpWidget( createTestWidget( const ChosenDietWidgetContent( - currentDietDay: 1, dietName: 'My Diet', - dietDuration: 7, dietId: 1, ), ), @@ -70,6 +66,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('My Diet'), findsOneWidget); + expect(find.textContaining('из 7'), findsNothing); }); }); } diff --git a/test/features/diary/presentation/widgets/diary_chosen_widgets_smoke_test.dart b/test/features/diary/presentation/widgets/diary_chosen_widgets_smoke_test.dart index dd7bc0f9..a5cbfaa1 100644 --- a/test/features/diary/presentation/widgets/diary_chosen_widgets_smoke_test.dart +++ b/test/features/diary/presentation/widgets/diary_chosen_widgets_smoke_test.dart @@ -100,6 +100,17 @@ void main() { await tester.pumpWidget(wrap(const ChosenDietWidget(), cubit)); await tester.pump(); expect(find.text('Diet'), findsOneWidget); + expect(find.textContaining('из 7'), findsNothing); + }); + + testWidgets('ChosenDietWidget hides diet day while plan keeps its day', (tester) async { + final cubit = FakeDiaryCubit(loadedState(hasDiet: true, hasPlan: true)); + await tester.pumpWidget(wrap(const Column(children: [ChosenPlanWidget(), ChosenDietWidget()]), cubit)); + await tester.pump(); + expect(find.text('Plan'), findsOneWidget); + expect(find.textContaining('из 30'), findsOneWidget); + expect(find.text('Diet'), findsOneWidget); + expect(find.textContaining('из 7'), findsNothing); }); testWidgets('AddProductButtonWidget renders for loaded state', (tester) async { diff --git a/test/features/order_info/presentation/info_by_order_screen_test.dart b/test/features/order_info/presentation/info_by_order_screen_test.dart index ea426152..2a34f6da 100644 --- a/test/features/order_info/presentation/info_by_order_screen_test.dart +++ b/test/features/order_info/presentation/info_by_order_screen_test.dart @@ -243,12 +243,12 @@ void main() { expect(find.byType(InfoByOrderScreen), findsOneWidget); }); - ChosenPlanModel donePlan() { + ChosenPlanModel donePlan({ChosenPlanStateEnum state = ChosenPlanStateEnum.stateDone}) { return ChosenPlanModel( id: 1, optionType: 'options1', startDate: DateTime(2024, 1, 1), - chosenPlanState: ChosenPlanStateEnum.stateDone, + chosenPlanState: state, chatId: 'chat-1', consultantId: 1, plan: const PlanModel( @@ -320,6 +320,7 @@ void main() { expect(find.byType(CollectChargeBanner), findsOneWidget); expect(find.text('Получить выплату'), findsOneWidget); + expect(find.text('Отменить консультацию'), findsNothing); }, ); @@ -337,6 +338,22 @@ void main() { expect(find.byType(CollectChargeBanner), findsNothing); expect(find.text('Получить выплату'), findsNothing); + expect(find.text('Отменить консультацию'), findsNothing); + }); + + testWidgets('shows cancel action for cancellable post-paid plan', ( + WidgetTester tester, + ) async { + when( + mockChatRepository.getChosenPlan(id: anyNamed('id')), + ).thenAnswer((_) async => donePlan(state: ChosenPlanStateEnum.stateStarted)); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget(createTestWidget(planId: 1)); + await tester.pumpAndSettle(); + }); + + expect(find.text('Отменить консультацию'), findsOneWidget); }); }); } diff --git a/test/features/plans/enums/chosen_plan_state_enum_test.dart b/test/features/plans/enums/chosen_plan_state_enum_test.dart index 3c963836..a82ef455 100644 --- a/test/features/plans/enums/chosen_plan_state_enum_test.dart +++ b/test/features/plans/enums/chosen_plan_state_enum_test.dart @@ -61,6 +61,47 @@ void main() { expect(ChosenPlanStateEnum.stateConfirmedByConsultant.isStateAfterPaidState(), isTrue); }); + test('canShowCancelAction returns true for cancellable states', () { + expect(ChosenPlanStateEnum.stateInited.canShowCancelAction, isTrue); + expect(ChosenPlanStateEnum.stateInCoordination.canShowCancelAction, isTrue); + expect(ChosenPlanStateEnum.stateStartDateHasBeenSet.canShowCancelAction, isTrue); + expect(ChosenPlanStateEnum.statePendingPayment.canShowCancelAction, isTrue); + expect(ChosenPlanStateEnum.statePaid.canShowCancelAction, isTrue); + expect(ChosenPlanStateEnum.stateStarted.canShowCancelAction, isTrue); + expect(ChosenPlanStateEnum.stateInProgress.canShowCancelAction, isTrue); + }); + + test('canShowCancelAction returns false for non-cancellable states', () { + expect(ChosenPlanStateEnum.personalDietSet.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateLastDayStarted.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateLastDayFinished.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateComplained.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateDone.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateChargeWasCollected.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateCanceledByTheInitiator.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.statePaymentFailed.canShowCancelAction, isFalse); + expect(ChosenPlanStateEnum.stateConfirmedByConsultant.canShowCancelAction, isFalse); + }); + + test('isCancelAfterPaidState returns true only for post-paid cancellable states', () { + expect(ChosenPlanStateEnum.statePaid.isCancelAfterPaidState, isTrue); + expect(ChosenPlanStateEnum.stateStarted.isCancelAfterPaidState, isTrue); + expect(ChosenPlanStateEnum.stateInProgress.isCancelAfterPaidState, isTrue); + expect(ChosenPlanStateEnum.stateInited.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateInCoordination.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateStartDateHasBeenSet.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.statePendingPayment.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.statePaymentFailed.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateConfirmedByConsultant.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.personalDietSet.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateLastDayStarted.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateLastDayFinished.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateComplained.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateDone.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateChargeWasCollected.isCancelAfterPaidState, isFalse); + expect(ChosenPlanStateEnum.stateCanceledByTheInitiator.isCancelAfterPaidState, isFalse); + }); + test('getActiveStates returns correct states', () { final activeStates = ChosenPlanStateEnum.getActiveStates(); From 6c4572af55c857df0f4bbbbaccb81289663a498e Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 6 Nov 2026 14:22:55 +0300 Subject: [PATCH 5/6] fix: show personal diet name and consultant notifications --- .../chat/presentation/pages/chat_screen.dart | 1 + .../models/consultation_model.dart | 1 + .../models/consultation_progress.dart | 2 + .../widgets/consultation_card_widget.dart | 7 ++- .../widgets/personal_diet_status_chip.dart | 6 ++- .../utils/consultation_progress_mapper.dart | 2 + .../models/notification_model.dart | 52 +++++++++++++++++++ .../notifications_cubit.dart | 6 +++ .../widgets/notification_widget.dart | 24 +++++++++ .../service/notifications_service.dart | 6 +++ .../plans/models/chosen_plan_model.dart | 1 + lib/generated/intl/messages_en.dart | 51 +++++++++--------- lib/generated/intl/messages_ru.dart | 15 +++--- lib/generated/l10n.dart | 10 ++++ lib/l10n/intl_en.arb | 6 +++ lib/l10n/intl_ru.arb | 6 +++ .../chat_consultation_header_test.dart | 6 ++- .../models/consultation_model_test.dart | 2 + .../consultation_card_widget_test.dart | 23 ++++++++ .../personal_diet_status_chip_test.dart | 25 ++++++++- .../consultation_progress_mapper_test.dart | 10 ++++ ...notification_model_fromjson_bulk_test.dart | 1 + .../models/notification_model_test.dart | 29 +++++++++++ .../widgets/notification_widget_test.dart | 25 +++++++++ .../service/notifications_service_test.dart | 37 +++++++++++++ .../plans/models/chosen_plan_model_test.dart | 3 ++ 26 files changed, 323 insertions(+), 34 deletions(-) diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 6ea9f554..ee1b351f 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -194,6 +194,7 @@ class _ChatScreenState extends State { : mapConsultationProgress( state: chosenPlan.chosenPlanState, hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false, + personalDietName: chosenPlan.personalDietName, ); final bannerType = resolveChatBanner( progress: progress, diff --git a/lib/features/consultations/models/consultation_model.dart b/lib/features/consultations/models/consultation_model.dart index dfd1c956..0e321e2c 100644 --- a/lib/features/consultations/models/consultation_model.dart +++ b/lib/features/consultations/models/consultation_model.dart @@ -24,6 +24,7 @@ abstract class ConsultationModel with _$ConsultationModel { @JsonKey(name: 'consultant_id') required int consultantId, required List diaries, @JsonKey(name: 'personal_diet_id') required int? personalDietId, + @JsonKey(name: 'personal_diet_name') String? personalDietName, }) = _ConsultationModel; factory ConsultationModel.fromJson(Map json) => _$ConsultationModelFromJson(json); diff --git a/lib/features/consultations/models/consultation_progress.dart b/lib/features/consultations/models/consultation_progress.dart index 044642d5..35f56f50 100644 --- a/lib/features/consultations/models/consultation_progress.dart +++ b/lib/features/consultations/models/consultation_progress.dart @@ -10,12 +10,14 @@ class ConsultationProgress { required this.showProgressPlank, required this.dietStatus, required this.phase, + this.personalDietName, }); final Set completedSteps; final bool showProgressPlank; final PersonalDietStatus dietStatus; final ConsultationPhase phase; + final String? personalDietName; bool isStepCompleted(ConsultationStep step) => completedSteps.contains(step); diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index 3dc0274d..8ad68bf8 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -97,7 +97,12 @@ class _ConsultationCardWidgetState extends State { borderRadius: BorderRadius.circular(100), ), child: Text( - s.personal_diet_proposed, + widget.consultationsModel.personalDietName == null || + widget.consultationsModel.personalDietName!.isEmpty + ? s.personal_diet_proposed + : s.personal_diet_proposed_named( + widget.consultationsModel.personalDietName!, + ), style: TextStyles.textXSSemibold.copyWith( color: appTheme.greenA700, ), diff --git a/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart b/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart index 0f33c501..559d1f39 100644 --- a/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart +++ b/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart @@ -42,8 +42,12 @@ class PersonalDietStatusChip extends StatelessWidget { return (s.payment_failed, appTheme.lightRed, appTheme.red); } if (progress.dietStatus == PersonalDietStatus.proposed) { + final dietName = progress.personalDietName; + final label = dietName == null || dietName.isEmpty + ? s.personal_diet_proposed + : s.personal_diet_proposed_named(dietName); return ( - s.personal_diet_proposed, + label, appTheme.transparentGreen, appTheme.greenA700, ); diff --git a/lib/features/consultations/utils/consultation_progress_mapper.dart b/lib/features/consultations/utils/consultation_progress_mapper.dart index 98b89d0c..c815c7a1 100644 --- a/lib/features/consultations/utils/consultation_progress_mapper.dart +++ b/lib/features/consultations/utils/consultation_progress_mapper.dart @@ -4,12 +4,14 @@ import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; ConsultationProgress mapConsultationProgress({ required ChosenPlanStateEnum state, required bool hasPersonalDiet, + String? personalDietName, }) { return ConsultationProgress( completedSteps: _completedSteps(state), showProgressPlank: _showProgressPlank(state), dietStatus: _dietStatus(state, hasPersonalDiet), phase: _phase(state), + personalDietName: personalDietName, ); } diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index b178eb91..222462cd 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -2174,6 +2174,56 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod } } +class PersonalDietIsDefinedToAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final int chosenPlanId; + final int dietId; + final String planName; + final String userName; + final String consultantName; + final String dietName; + final String role; + final String peerSlug; + final String peerImagePath; + + PersonalDietIsDefinedToAuthorNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.chosenPlanId, + required this.dietId, + required this.planName, + required this.userName, + required this.consultantName, + required this.dietName, + required this.role, + required this.peerSlug, + required this.peerImagePath, + }); + + factory PersonalDietIsDefinedToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PersonalDietIsDefinedToAuthorNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + chosenPlanId: json['chosen_plan_id'] ?? 0, + dietId: json['diet_id'] ?? 0, + planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + dietName: json['diet_name'] ?? '', + role: json['role']?.toString() ?? '', + peerSlug: json['peer_slug'] ?? '', + peerImagePath: json['peer_image_path'] ?? '', + ); + } +} + // New Model Classes for Comprehensive Support class ConsultantEducationUpdatedNotification implements BaseNotificationModel { @@ -2480,6 +2530,7 @@ enum NotificationType { chosenPlanFinishedToConsultant, stayOnTrackToCustomer, personalDietIsDefinedToCustomer, + personalDietIsDefinedToAuthor, // Comments entityCommented, @@ -2602,6 +2653,7 @@ enum NotificationType { 'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer, 'diary-reminder-for-users' => NotificationType.diaryReminderForUsers, 'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer, + 'personal-diet-is-defined-to-author' => NotificationType.personalDietIsDefinedToAuthor, // New additions 'consultant-education-updated' => NotificationType.consultantEducationUpdated, diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index c750c513..0a30a0db 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -277,6 +277,12 @@ class NotificationsCubit extends Cubit { isRead: isRead, createdAt: createdAt, ), + NotificationType.personalDietIsDefinedToAuthor => PersonalDietIsDefinedToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson( notificationJson, type, diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index 26714425..ed3c4dab 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -122,6 +122,9 @@ class NotificationWidget extends StatelessWidget { PersonalDietIsDefinedToCustomerNotification() => _PersonalDietIsDefinedToCustomerWidget( notification: notification as PersonalDietIsDefinedToCustomerNotification, ), + PersonalDietIsDefinedToAuthorNotification() => _PersonalDietIsDefinedToAuthorWidget( + notification: notification as PersonalDietIsDefinedToAuthorNotification, + ), // New Widgets ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget(notification: notification as ConsultantEducationUpdatedNotification), ComplaintRegisteredNotification() => _ComplaintRegisteredWidget(notification: notification as ComplaintRegisteredNotification), @@ -1273,6 +1276,27 @@ class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget { } } +class _PersonalDietIsDefinedToAuthorWidget extends StatelessWidget { + const _PersonalDietIsDefinedToAuthorWidget({required this.notification}); + final PersonalDietIsDefinedToAuthorNotification notification; + @override + Widget build(BuildContext context) { + final userName = notification.userName.isNotEmpty ? notification.userName : 'Пользователь'; + final dietName = notification.dietName.isNotEmpty ? ', выбранная диета: «${notification.dietName}»' : ''; + + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Вы предложили персональную диету пользователю $userName$dietName', + createdAt: notification.createdAt, + onTap: notification.peerSlug.isEmpty + ? null + : () { + context.push(RouteConstants.profileScreen, extra: notification.peerSlug); + }, + ); + } +} + class _ConsultantEducationUpdatedWidget extends StatelessWidget { const _ConsultantEducationUpdatedWidget({required this.notification}); final ConsultantEducationUpdatedNotification notification; diff --git a/lib/features/notifications/service/notifications_service.dart b/lib/features/notifications/service/notifications_service.dart index c934fb53..dbe2d16c 100644 --- a/lib/features/notifications/service/notifications_service.dart +++ b/lib/features/notifications/service/notifications_service.dart @@ -413,6 +413,12 @@ class NotificationsService { isRead: readDate != null, createdAt: createdAt, ), + NotificationType.personalDietIsDefinedToAuthor => PersonalDietIsDefinedToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson( notificationJson['data'], type, diff --git a/lib/features/plans/models/chosen_plan_model.dart b/lib/features/plans/models/chosen_plan_model.dart index 9ac84c85..de1e61d3 100644 --- a/lib/features/plans/models/chosen_plan_model.dart +++ b/lib/features/plans/models/chosen_plan_model.dart @@ -15,6 +15,7 @@ abstract class ChosenPlanModel with _$ChosenPlanModel { @JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState, @JsonKey(name: 'chat_id') required String chatId, @JsonKey(name: 'consultant_id') required int consultantId, + @JsonKey(name: 'personal_diet_name') String? personalDietName, @JsonKey(name: 'plan') required PlanModel plan, }) = _ChosenPlanModel; diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 674fc983..8a5fffea 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -28,36 +28,38 @@ class MessageLookup extends MessageLookupByLibrary { static String m2(dietDuration, dietDayWord, planDuration, planDayWord) => "Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}."; - static String m3(planName) => "План «${planName}» начался"; + static String m3(name) => "Diet «${name}» proposed"; - static String m4(planName, name) => + static String m4(planName) => "План «${planName}» начался"; + + static String m5(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m5(duration, dayWord) => "Plan for ${duration} ${dayWord}"; + static String m6(duration, dayWord) => "Plan for ${duration} ${dayWord}"; - static String m6(percent, amount) => + static String m7(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m7(date) => "Start: ${date}"; + static String m8(date) => "Start: ${date}"; - static String m8(step) => "шаг ${step} из 2"; + static String m9(step) => "шаг ${step} из 2"; - static String m9(planName, name) => + static String m10(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m10(planName) => + static String m11(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m11(name, planName) => + static String m12(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m12(name, planName) => + static String m13(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m13(name, planName) => + static String m14(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m14(planName) => "Вы отменили подписку на план «${planName}»"; + static String m15(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -696,6 +698,7 @@ class MessageLookup extends MessageLookupByLibrary { "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( "Personal diet proposed", ), + "personal_diet_proposed_named": m3, "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( "The change takes effect tomorrow", ), @@ -715,18 +718,18 @@ class MessageLookup extends MessageLookupByLibrary { "Физическая форма", ), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), - "planStarted": m3, + "planStarted": m4, "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "Если вы не подтвердите, то план через три дня подтвердится автоматически", ), - "planWithUserStarted": m4, + "planWithUserStarted": m5, "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc_example": MessageLookupByLibrary.simpleMessage( "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), - "plan_duration_label": m5, + "plan_duration_label": m6, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -763,7 +766,7 @@ class MessageLookup extends MessageLookupByLibrary { "Promo code applied!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), - "promo_discount_hint": m6, + "promo_discount_hint": m7, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), @@ -884,13 +887,13 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m7, + "start_date_label": m8, "statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "step": MessageLookupByLibrary.simpleMessage("Шаг"), "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m8, + "step_widget": m9, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -915,8 +918,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m9, - "tomorrowYourPlanWillStart": m10, + "tommorowPlanWithUserWillStart": m10, + "tomorrowYourPlanWillStart": m11, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -950,7 +953,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m11, + "userCanceldThePlanSubscription": m12, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -958,8 +961,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m12, - "userSubscribedToPlan": m13, + "userPaidThePlan": m13, + "userSubscribedToPlan": m14, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1089,7 +1092,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m14, + "youCanceledSubscriptionToPlan": m15, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 40408fe2..a591ebbd 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -26,12 +26,14 @@ class MessageLookup extends MessageLookupByLibrary { static String m2(dietDuration, dietDayWord, planDuration, planDayWord) => "Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}."; - static String m5(duration, dayWord) => "План на ${duration} ${dayWord}"; + static String m3(name) => "Диета «${name}» предложена"; - static String m6(percent, amount) => + static String m6(duration, dayWord) => "План на ${duration} ${dayWord}"; + + static String m7(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; - static String m7(date) => "Старт: ${date}"; + static String m8(date) => "Старт: ${date}"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -340,6 +342,7 @@ class MessageLookup extends MessageLookupByLibrary { "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( "Диета предложена", ), + "personal_diet_proposed_named": m3, "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( "Замена действует со следующего дня", ), @@ -355,7 +358,7 @@ class MessageLookup extends MessageLookupByLibrary { "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), - "plan_duration_label": m5, + "plan_duration_label": m6, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -383,7 +386,7 @@ class MessageLookup extends MessageLookupByLibrary { "Промокод применён!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), - "promo_discount_hint": m6, + "promo_discount_hint": m7, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), @@ -444,7 +447,7 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m7, + "start_date_label": m8, "suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"), "tell_your_schedule": MessageLookupByLibrary.simpleMessage( "Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 1c3bf033..5f333859 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4574,6 +4574,16 @@ class S { ); } + /// `Diet «{name}» proposed` + String personal_diet_proposed_named(String name) { + return Intl.message( + 'Diet «$name» proposed', + name: 'personal_diet_proposed_named', + desc: '', + args: [name], + ); + } + /// `Personal diet not proposed` String get personal_diet_not_proposed { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 980a696d..4249132f 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -540,6 +540,12 @@ "video_call_notification_end": "End call", "order_in_progress": "Order in progress", "personal_diet_proposed": "Personal diet proposed", + "personal_diet_proposed_named": "Diet «{name}» proposed", + "@personal_diet_proposed_named": { + "placeholders": { + "name": {"type": "String"} + } + }, "personal_diet_not_proposed": "Personal diet not proposed", "change_personal_diet": "Change personal diet", "personal_diet_replaces_tomorrow": "The change takes effect tomorrow", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 143c497d..17c31301 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -295,6 +295,12 @@ "choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты", "order_in_progress": "Заказ в работе", "personal_diet_proposed": "Диета предложена", + "personal_diet_proposed_named": "Диета «{name}» предложена", + "@personal_diet_proposed_named": { + "placeholders": { + "name": {"type": "String"} + } + }, "personal_diet_not_proposed": "Диета не предложена", "change_personal_diet": "Заменить диету", "personal_diet_replaces_tomorrow": "Замена действует со следующего дня", diff --git a/test/features/chat/presentation/widgets/chat_consultation_header_test.dart b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart index 96c10b05..887e593e 100644 --- a/test/features/chat/presentation/widgets/chat_consultation_header_test.dart +++ b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart @@ -55,6 +55,7 @@ void main() { final progress = mapConsultationProgress( state: ChosenPlanStateEnum.personalDietSet, hasPersonalDiet: true, + personalDietName: 'Индивидуальный план питания', ); expect(progress.showProgressPlank, isTrue); expect(progress.dietStatus, PersonalDietStatus.proposed); @@ -73,7 +74,10 @@ void main() { expect(find.text(s.order_in_progress), findsNWidgets(2)); expect(find.byType(PersonalDietStatusChip), findsOneWidget); - expect(find.text(s.personal_diet_proposed), findsOneWidget); + expect( + find.text(s.personal_diet_proposed_named('Индивидуальный план питания')), + findsOneWidget, + ); expect(find.byType(ConsultationProgressPlank), findsOneWidget); expect(find.byIcon(Icons.check), findsNWidgets(2)); }, diff --git a/test/features/consultations/models/consultation_model_test.dart b/test/features/consultations/models/consultation_model_test.dart index 64ac25f9..bd00e5c8 100644 --- a/test/features/consultations/models/consultation_model_test.dart +++ b/test/features/consultations/models/consultation_model_test.dart @@ -174,11 +174,13 @@ void main() { 'consultant_id': 1, 'diaries': [], 'personal_diet_id': null, + 'personal_diet_name': 'Индивидуальный план питания', }; final model = ConsultationModel.fromJson(json); expect(model.chatId, 'direct-chat-uuid'); + expect(model.personalDietName, 'Индивидуальный план питания'); }); test('fromJson allows missing chat_id', () { diff --git a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart index 46c5e042..6e1a6083 100644 --- a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart +++ b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart @@ -153,6 +153,29 @@ void main() { }); }); + testWidgets('shows personal diet name in status chip', (tester) async { + final consultation = mockConsultation.copyWith( + personalDietId: 1, + personalDietName: 'Индивидуальный план питания', + ); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap(ConsultationCardWidget(consultationsModel: consultation)), + ); + await tester.pumpAndSettle(); + + final s = S.of(tester.element(find.byType(ConsultationCardWidget))); + + expect( + find.text( + s.personal_diet_proposed_named('Индивидуальный план питания'), + ), + findsOneWidget, + ); + }); + }); + testWidgets('renders diary days', (tester) async { await mockNetworkImagesFor(() async { await tester.pumpWidget( 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 index d8fbb9f1..68ea86e4 100644 --- a/test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart +++ b/test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart @@ -21,11 +21,16 @@ Widget _wrap(Widget child) { ); } -Widget _chip(ChosenPlanStateEnum state, {bool hasPersonalDiet = true}) { +Widget _chip( + ChosenPlanStateEnum state, { + bool hasPersonalDiet = true, + String? personalDietName, +}) { return PersonalDietStatusChip( progress: mapConsultationProgress( state: state, hasPersonalDiet: hasPersonalDiet, + personalDietName: personalDietName, ), ); } @@ -55,6 +60,24 @@ void main() { expect(find.text(s.personal_diet_proposed), findsOneWidget); }); + testWidgets('proposed с названием показывает имя диеты', (tester) async { + await tester.pumpWidget( + _wrap( + _chip( + ChosenPlanStateEnum.stateStarted, + personalDietName: 'Индивидуальный план питания', + ), + ), + ); + + final s = S.of(tester.element(find.byType(PersonalDietStatusChip))); + + expect( + find.text(s.personal_diet_proposed_named('Индивидуальный план питания')), + findsOneWidget, + ); + }); + testWidgets('notProposed показывает personal_diet_not_proposed', ( tester, ) async { diff --git a/test/features/consultations/utils/consultation_progress_mapper_test.dart b/test/features/consultations/utils/consultation_progress_mapper_test.dart index 16c0f1c5..ac0ded6c 100644 --- a/test/features/consultations/utils/consultation_progress_mapper_test.dart +++ b/test/features/consultations/utils/consultation_progress_mapper_test.dart @@ -275,5 +275,15 @@ void main() { ); } }); + + test('personalDietName передаётся в ConsultationProgress', () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + personalDietName: 'Индивидуальный план питания', + ); + + expect(progress.personalDietName, 'Индивидуальный план питания'); + }); }); } diff --git a/test/features/notifications/models/notification_model_fromjson_bulk_test.dart b/test/features/notifications/models/notification_model_fromjson_bulk_test.dart index 8ee7fe97..771518c7 100644 --- a/test/features/notifications/models/notification_model_fromjson_bulk_test.dart +++ b/test/features/notifications/models/notification_model_fromjson_bulk_test.dart @@ -92,6 +92,7 @@ void main() { DiaryReminderForUsersNotification.fromJson(base, NotificationType.diaryReminderForUsers); DiaryReminderForUsersNotification.fromJson({'created_at': c, 'data': {}}, NotificationType.diaryReminderForUsers); PersonalDietIsDefinedToCustomerNotification.fromJson(base, NotificationType.personalDietIsDefinedToCustomer); + PersonalDietIsDefinedToAuthorNotification.fromJson(base, NotificationType.personalDietIsDefinedToAuthor); ConsultantEducationUpdatedNotification.fromJson(base, NotificationType.consultantEducationUpdated); ComplaintRegisteredNotification.fromJson(base, NotificationType.complaintRegistered); ContentUnpublishedNotification.fromJson(base, NotificationType.contentUnpublished); diff --git a/test/features/notifications/models/notification_model_test.dart b/test/features/notifications/models/notification_model_test.dart index d7f809e0..0c89e2fd 100644 --- a/test/features/notifications/models/notification_model_test.dart +++ b/test/features/notifications/models/notification_model_test.dart @@ -50,6 +50,7 @@ void main() { 'stay-on-track-to-customer': NotificationType.stayOnTrackToCustomer, 'diary-reminder-for-users': NotificationType.diaryReminderForUsers, 'personal-diet-is-defined-to-customer': NotificationType.personalDietIsDefinedToCustomer, + 'personal-diet-is-defined-to-author': NotificationType.personalDietIsDefinedToAuthor, }; cases.forEach((key, expected) { @@ -671,6 +672,34 @@ void main() { expect(notification.type, NotificationType.personalizedDietIsDefined); }); + test('PersonalDietIsDefinedToAuthorNotification.fromJson parses all fields', () { + final json = { + 'chosen_plan_id': 12, + 'diet_id': 34, + 'plan_name': 'Plan', + 'user_name': 'Client', + 'consultant_name': 'Consultant', + 'diet_name': 'Personal diet', + 'role': 'consultant', + 'peer_slug': 'client-slug', + 'peer_image_path': 'avatars/client.png', + 'created_at': '2024-01-30T12:00:00Z', + }; + + final notification = PersonalDietIsDefinedToAuthorNotification.fromJson( + json, + NotificationType.personalDietIsDefinedToAuthor, + isRead: false, + ); + + expect(notification.chosenPlanId, 12); + expect(notification.dietId, 34); + expect(notification.userName, 'Client'); + expect(notification.dietName, 'Personal diet'); + expect(notification.peerSlug, 'client-slug'); + expect(notification.type, NotificationType.personalDietIsDefinedToAuthor); + }); + test('ChosenPlanFinishedToClientNotification.fromJson parses all fields', () { final json = { 'plan_name': 'Finished Plan', diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index 2127155b..fdb04862 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -1212,6 +1212,31 @@ void main() { expect(find.byType(NotificationWidget), findsOneWidget); }); + testWidgets('displays PersonalDietIsDefinedToAuthorNotification', ( + tester, + ) async { + final notification = PersonalDietIsDefinedToAuthorNotification( + type: NotificationType.personalDietIsDefinedToAuthor, + isRead: false, + createdAt: testDate, + chosenPlanId: 1, + dietId: 1, + planName: 'Plan', + userName: 'Client', + consultantName: 'Consultant', + dietName: 'Test Diet', + role: 'consultant', + peerSlug: 'client-slug', + peerImagePath: '', + ); + + await tester.pumpWidget(createTestWidget(notification)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Вы предложили персональную диету пользователю Client'), findsOneWidget); + expect(find.textContaining('Test Diet'), findsOneWidget); + }); + testWidgets('displays UnknownNotification', (tester) async { final notification = UnknownNotification( type: NotificationType.unknown, diff --git a/test/features/notifications/service/notifications_service_test.dart b/test/features/notifications/service/notifications_service_test.dart index 5c2b62ab..4effb8f3 100644 --- a/test/features/notifications/service/notifications_service_test.dart +++ b/test/features/notifications/service/notifications_service_test.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/notifications/models/notification_model.dart'; import 'package:eatvio_app/features/notifications/service/notifications_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -1454,6 +1455,42 @@ void main() { expect(result, isNotNull); expect(result!['notifications'], isA()); expect(result['notifications'].length, 1); + expect(result['notifications'].first, isA()); + }); + + test('getNotifications - обрабатывает PersonalDietIsDefinedToAuthor', () async { + const page = 1; + final response = Response( + data: { + 'data': [ + { + 'type': 'personal-diet-is-defined-to-author', + 'data': { + 'diet_name': 'Test Diet', + 'diet_id': 1, + 'user_name': 'Client', + }, + 'read_at': null, + 'created_at': '2024-01-01T12:00:00Z', + }, + ], + 'current_page': 1, + 'last_page': 1, + 'per_page': 10, + 'total': 1, + }, + statusCode: 200, + requestOptions: RequestOptions(path: '/api/profile/notifications'), + ); + + when(mockDio.get(any)).thenAnswer((_) async => response); + + final result = await notificationsService.getNotifications(page: page); + + expect(result, isNotNull); + expect(result!['notifications'], isA()); + expect(result['notifications'].length, 1); + expect(result['notifications'].first, isA()); }); test('getNotifications - обрабатывает UserDailyReportCompleted', () async { diff --git a/test/features/plans/models/chosen_plan_model_test.dart b/test/features/plans/models/chosen_plan_model_test.dart index a3100298..5f53ba22 100644 --- a/test/features/plans/models/chosen_plan_model_test.dart +++ b/test/features/plans/models/chosen_plan_model_test.dart @@ -60,6 +60,7 @@ void main() { 'last_state': 'initied', 'chat_id': 'chat-1', 'consultant_id': 1, + 'personal_diet_name': 'Индивидуальный план питания', 'plan': { 'id': 1, 'slug': 'test-plan', @@ -94,6 +95,7 @@ void main() { expect(chosenPlan.optionType, 'options1'); expect(chosenPlan.chosenPlanState, ChosenPlanStateEnum.stateInited); expect(chosenPlan.chatId, 'chat-1'); + expect(chosenPlan.personalDietName, 'Индивидуальный план питания'); }); test('toJson serializes ChosenPlanModel correctly', () { @@ -207,6 +209,7 @@ void main() { 'last_state': 'initied', 'chat_id': 'chat-1', 'consultant_id': 1, + 'personal_diet_name': 'Индивидуальный план питания', 'plan': { 'id': 1, 'slug': 'test-plan', From 28ae75474aca432cab740cbe707096a698fc1c1c Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Tue, 10 Nov 2026 13:52:06 +0300 Subject: [PATCH 6/6] fix: localize diet notification and type plan options --- .../chat/presentation/pages/chat_screen.dart | 1 - .../widgets/notification_widget.dart | 9 ++- .../presentation/info_by_order_screen.dart | 10 +-- .../plans/enums/plan_option_type_enum.dart | 29 ++++++++ .../plans/models/chosen_plan_model.dart | 14 ++-- .../presentation/cubit/about_plan_cubit.dart | 9 +-- lib/generated/intl/messages_en.dart | 69 +++++++++++-------- lib/generated/intl/messages_ru.dart | 33 +++++---- lib/generated/l10n.dart | 28 ++++++++ lib/l10n/intl_en.arb | 14 ++++ lib/l10n/intl_ru.arb | 14 ++++ .../widgets/notification_widget_test.dart | 62 ++++++++++++++++- .../plans/models/chosen_plan_model_test.dart | 60 ++++++++++++++++ 13 files changed, 281 insertions(+), 71 deletions(-) create mode 100644 lib/features/plans/enums/plan_option_type_enum.dart diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index ee1b351f..5c8db98c 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -30,7 +30,6 @@ import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.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'; diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index ed3c4dab..2c6dfd2e 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -1281,12 +1281,15 @@ class _PersonalDietIsDefinedToAuthorWidget extends StatelessWidget { final PersonalDietIsDefinedToAuthorNotification notification; @override Widget build(BuildContext context) { - final userName = notification.userName.isNotEmpty ? notification.userName : 'Пользователь'; - final dietName = notification.dietName.isNotEmpty ? ', выбранная диета: «${notification.dietName}»' : ''; + final s = S.of(context); + final userName = notification.userName.isNotEmpty ? notification.userName : s.default_user_name; + final notificationsText = notification.dietName.isEmpty + ? s.personal_diet_defined_to_author(userName) + : s.personal_diet_defined_to_author_named(userName, notification.dietName); return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Вы предложили персональную диету пользователю $userName$dietName', + notificationsText: notificationsText, createdAt: notification.createdAt, onTap: notification.peerSlug.isEmpty ? null diff --git a/lib/features/order_info/presentation/info_by_order_screen.dart b/lib/features/order_info/presentation/info_by_order_screen.dart index 40a5f8c2..f6fd1771 100644 --- a/lib/features/order_info/presentation/info_by_order_screen.dart +++ b/lib/features/order_info/presentation/info_by_order_screen.dart @@ -6,7 +6,6 @@ import 'package:eatvio_app/features/cancel_plan_bottom_sheet/cancel_plan_bottom_ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart'; -import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart'; import 'package:eatvio_app/features/order_info/cubit/order_info_state.dart'; import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart'; @@ -106,14 +105,7 @@ class _InfoByOrderScreenContentState extends State { ), ); } - PacketOptions? chosenOption; - if (plan.optionType == 'options1') { - chosenOption = plan.plan.packet1Options; - } else if (plan.optionType == 'options2') { - chosenOption = plan.plan.packet2Options; - } else { - chosenOption = plan.plan.packet3Options; - } + final chosenOption = plan.chosenOption; PlanStatusValues planStatus; if (widget.successfulPayment == true) { diff --git a/lib/features/plans/enums/plan_option_type_enum.dart b/lib/features/plans/enums/plan_option_type_enum.dart new file mode 100644 index 00000000..4b231bdc --- /dev/null +++ b/lib/features/plans/enums/plan_option_type_enum.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +enum PlanOptionType { + @JsonValue('options1') + options1('options1'), + @JsonValue('options2') + options2('options2'), + @JsonValue('options3') + options3('options3'); + + const PlanOptionType(this.apiValue); + + final String apiValue; + + static PlanOptionType? tryParse(String? raw) { + for (final option in values) { + if (option.apiValue == raw) { + return option; + } + } + return null; + } + + static PlanOptionType fromIndex(int index) => switch (index) { + 1 => PlanOptionType.options2, + 2 => PlanOptionType.options3, + _ => PlanOptionType.options1, + }; +} diff --git a/lib/features/plans/models/chosen_plan_model.dart b/lib/features/plans/models/chosen_plan_model.dart index de1e61d3..73724a56 100644 --- a/lib/features/plans/models/chosen_plan_model.dart +++ b/lib/features/plans/models/chosen_plan_model.dart @@ -1,5 +1,6 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:eatvio_app/features/plans/enums/plan_option_type_enum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chosen_plan_model.freezed.dart'; @@ -7,6 +8,7 @@ part 'chosen_plan_model.g.dart'; @freezed abstract class ChosenPlanModel with _$ChosenPlanModel { + const ChosenPlanModel._(); factory ChosenPlanModel({ @JsonKey() required int id, @@ -20,18 +22,16 @@ abstract class ChosenPlanModel with _$ChosenPlanModel { }) = _ChosenPlanModel; factory ChosenPlanModel.fromJson(Map json) => _$ChosenPlanModelFromJson(json); -} -extension ChosenPlanModelX on ChosenPlanModel { PacketOptions? get chosenOption { - switch (optionType) { - case 'options1': + switch (PlanOptionType.tryParse(optionType)) { + case PlanOptionType.options1: return plan.packet1Options; - case 'options2': + case PlanOptionType.options2: return plan.packet2Options; - case 'options3': + case PlanOptionType.options3: return plan.packet3Options; - default: + case null: return null; } } diff --git a/lib/features/plans/presentation/cubit/about_plan_cubit.dart b/lib/features/plans/presentation/cubit/about_plan_cubit.dart index 2c5b6b72..6cd354aa 100644 --- a/lib/features/plans/presentation/cubit/about_plan_cubit.dart +++ b/lib/features/plans/presentation/cubit/about_plan_cubit.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:easy_debounce/easy_debounce.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/diets/repositories/diet_repository.dart'; +import 'package:eatvio_app/features/plans/enums/plan_option_type_enum.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart'; import 'package:eatvio_app/features/plans/repositories/plan_repository.dart'; @@ -133,7 +134,7 @@ class AboutPlanCubit extends Cubit { final slug = state.planModel?.slug; final optionIndex = state.chosenOptionIndex; if (slug != null) { - final optionName = _getOptionNameFromIndex(optionIndex); + final optionName = PlanOptionType.fromIndex(optionIndex).apiValue; final body = {'options_type': optionName}; return (await planRepository.chosePlan(slug, body), null); } @@ -181,10 +182,4 @@ class AboutPlanCubit extends Cubit { return false; } } - - String _getOptionNameFromIndex(int index) => switch (index) { - 1 => 'options2', - 2 => 'options3', - _ => 'options1', - }; } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 8a5fffea..99a1c3c0 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -22,44 +22,50 @@ class MessageLookup extends MessageLookupByLibrary { static String m0(date) => "Консультант прокомментировал день ${date}"; - static String m1(duration, dayWord) => + static String m1(userName) => + "You have proposed a personal diet to ${userName}"; + + static String m2(userName, dietName) => + "You have proposed a personal diet to ${userName}, selected diet: «${dietName}»"; + + static String m3(duration, dayWord) => "The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration."; - static String m2(dietDuration, dietDayWord, planDuration, planDayWord) => + static String m4(dietDuration, dietDayWord, planDuration, planDayWord) => "Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}."; - static String m3(name) => "Diet «${name}» proposed"; + static String m5(name) => "Diet «${name}» proposed"; - static String m4(planName) => "План «${planName}» начался"; + static String m6(planName) => "План «${planName}» начался"; - static String m5(planName, name) => + static String m7(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m6(duration, dayWord) => "Plan for ${duration} ${dayWord}"; + static String m8(duration, dayWord) => "Plan for ${duration} ${dayWord}"; - static String m7(percent, amount) => + static String m9(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m8(date) => "Start: ${date}"; + static String m10(date) => "Start: ${date}"; - static String m9(step) => "шаг ${step} из 2"; + static String m11(step) => "шаг ${step} из 2"; - static String m10(planName, name) => + static String m12(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m11(planName) => + static String m13(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m12(name, planName) => + static String m14(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m13(name, planName) => + static String m15(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m14(name, planName) => + static String m16(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m15(planName) => "Вы отменили подписку на план «${planName}»"; + static String m17(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -332,6 +338,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "days": MessageLookupByLibrary.simpleMessage("дней"), "deception": MessageLookupByLibrary.simpleMessage("Обман"), + "default_user_name": MessageLookupByLibrary.simpleMessage("User"), "delete": MessageLookupByLibrary.simpleMessage("Удалить"), "deleteComment": MessageLookupByLibrary.simpleMessage( "Удалить комментарий", @@ -690,15 +697,17 @@ class MessageLookup extends MessageLookupByLibrary { "personalDietCreating": MessageLookupByLibrary.simpleMessage( "Составление индивидуальной диеты", ), - "personal_diet_duration_hint": m1, - "personal_diet_duration_mismatch": m2, + "personal_diet_defined_to_author": m1, + "personal_diet_defined_to_author_named": m2, + "personal_diet_duration_hint": m3, + "personal_diet_duration_mismatch": m4, "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( "Personal diet not proposed", ), "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( "Personal diet proposed", ), - "personal_diet_proposed_named": m3, + "personal_diet_proposed_named": m5, "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( "The change takes effect tomorrow", ), @@ -718,18 +727,18 @@ class MessageLookup extends MessageLookupByLibrary { "Физическая форма", ), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), - "planStarted": m4, + "planStarted": m6, "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "Если вы не подтвердите, то план через три дня подтвердится автоматически", ), - "planWithUserStarted": m5, + "planWithUserStarted": m7, "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc_example": MessageLookupByLibrary.simpleMessage( "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), - "plan_duration_label": m6, + "plan_duration_label": m8, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -766,7 +775,7 @@ class MessageLookup extends MessageLookupByLibrary { "Promo code applied!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), - "promo_discount_hint": m7, + "promo_discount_hint": m9, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), @@ -887,13 +896,13 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m8, + "start_date_label": m10, "statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "step": MessageLookupByLibrary.simpleMessage("Шаг"), "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m9, + "step_widget": m11, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -918,8 +927,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m10, - "tomorrowYourPlanWillStart": m11, + "tommorowPlanWithUserWillStart": m12, + "tomorrowYourPlanWillStart": m13, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -953,7 +962,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m12, + "userCanceldThePlanSubscription": m14, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -961,8 +970,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m13, - "userSubscribedToPlan": m14, + "userPaidThePlan": m15, + "userSubscribedToPlan": m16, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1092,7 +1101,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m15, + "youCanceledSubscriptionToPlan": m17, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index a591ebbd..5f6457d6 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -20,20 +20,26 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'ru'; - static String m1(duration, dayWord) => + static String m1(userName) => + "Вы предложили персональную диету пользователю ${userName}"; + + static String m2(userName, dietName) => + "Вы предложили персональную диету пользователю ${userName}, выбранная диета: «${dietName}»"; + + static String m3(duration, dayWord) => "Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью."; - static String m2(dietDuration, dietDayWord, planDuration, planDayWord) => + static String m4(dietDuration, dietDayWord, planDuration, planDayWord) => "Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}."; - static String m3(name) => "Диета «${name}» предложена"; + static String m5(name) => "Диета «${name}» предложена"; - static String m6(duration, dayWord) => "План на ${duration} ${dayWord}"; + static String m8(duration, dayWord) => "План на ${duration} ${dayWord}"; - static String m7(percent, amount) => + static String m9(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; - static String m8(date) => "Старт: ${date}"; + static String m10(date) => "Старт: ${date}"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -130,6 +136,7 @@ class MessageLookup extends MessageLookupByLibrary { "date_of_birth": MessageLookupByLibrary.simpleMessage("Дата рождения"), "day": MessageLookupByLibrary.simpleMessage("День"), "days": MessageLookupByLibrary.simpleMessage("дней"), + "default_user_name": MessageLookupByLibrary.simpleMessage("Пользователь"), "deleted": MessageLookupByLibrary.simpleMessage("Удалена"), "diary": MessageLookupByLibrary.simpleMessage("Дневник"), "diet": MessageLookupByLibrary.simpleMessage("Диета"), @@ -334,15 +341,17 @@ class MessageLookup extends MessageLookupByLibrary { "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"), - "personal_diet_duration_hint": m1, - "personal_diet_duration_mismatch": m2, + "personal_diet_defined_to_author": m1, + "personal_diet_defined_to_author_named": m2, + "personal_diet_duration_hint": m3, + "personal_diet_duration_mismatch": m4, "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( "Диета не предложена", ), "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( "Диета предложена", ), - "personal_diet_proposed_named": m3, + "personal_diet_proposed_named": m5, "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( "Замена действует со следующего дня", ), @@ -358,7 +367,7 @@ class MessageLookup extends MessageLookupByLibrary { "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), - "plan_duration_label": m6, + "plan_duration_label": m8, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -386,7 +395,7 @@ class MessageLookup extends MessageLookupByLibrary { "Промокод применён!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), - "promo_discount_hint": m7, + "promo_discount_hint": m9, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), @@ -447,7 +456,7 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m8, + "start_date_label": m10, "suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"), "tell_your_schedule": MessageLookupByLibrary.simpleMessage( "Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 5f333859..fc12f142 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4584,6 +4584,34 @@ class S { ); } + /// `User` + String get default_user_name { + return Intl.message('User', name: 'default_user_name', desc: '', args: []); + } + + /// `You have proposed a personal diet to {userName}` + String personal_diet_defined_to_author(String userName) { + return Intl.message( + 'You have proposed a personal diet to $userName', + name: 'personal_diet_defined_to_author', + desc: '', + args: [userName], + ); + } + + /// `You have proposed a personal diet to {userName}, selected diet: «{dietName}»` + String personal_diet_defined_to_author_named( + String userName, + String dietName, + ) { + return Intl.message( + 'You have proposed a personal diet to $userName, selected diet: «$dietName»', + name: 'personal_diet_defined_to_author_named', + desc: '', + args: [userName, dietName], + ); + } + /// `Personal diet not proposed` String get personal_diet_not_proposed { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 4249132f..becf3334 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -546,6 +546,20 @@ "name": {"type": "String"} } }, + "default_user_name": "User", + "personal_diet_defined_to_author": "You have proposed a personal diet to {userName}", + "@personal_diet_defined_to_author": { + "placeholders": { + "userName": {"type": "String"} + } + }, + "personal_diet_defined_to_author_named": "You have proposed a personal diet to {userName}, selected diet: «{dietName}»", + "@personal_diet_defined_to_author_named": { + "placeholders": { + "userName": {"type": "String"}, + "dietName": {"type": "String"} + } + }, "personal_diet_not_proposed": "Personal diet not proposed", "change_personal_diet": "Change personal diet", "personal_diet_replaces_tomorrow": "The change takes effect tomorrow", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 17c31301..298a587a 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -301,6 +301,20 @@ "name": {"type": "String"} } }, + "default_user_name": "Пользователь", + "personal_diet_defined_to_author": "Вы предложили персональную диету пользователю {userName}", + "@personal_diet_defined_to_author": { + "placeholders": { + "userName": {"type": "String"} + } + }, + "personal_diet_defined_to_author_named": "Вы предложили персональную диету пользователю {userName}, выбранная диета: «{dietName}»", + "@personal_diet_defined_to_author_named": { + "placeholders": { + "userName": {"type": "String"}, + "dietName": {"type": "String"} + } + }, "personal_diet_not_proposed": "Диета не предложена", "change_personal_diet": "Заменить диету", "personal_diet_replaces_tomorrow": "Замена действует со следующего дня", diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index fdb04862..24d6ec92 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -1233,10 +1233,68 @@ void main() { await tester.pumpWidget(createTestWidget(notification)); await tester.pumpAndSettle(); - expect(find.textContaining('Вы предложили персональную диету пользователю Client'), findsOneWidget); - expect(find.textContaining('Test Diet'), findsOneWidget); + expect( + find.text('Вы предложили персональную диету пользователю Client, выбранная диета: «Test Diet»'), + findsOneWidget, + ); }); + testWidgets( + 'displays PersonalDietIsDefinedToAuthorNotification with fallback user name', + (tester) async { + final notification = PersonalDietIsDefinedToAuthorNotification( + type: NotificationType.personalDietIsDefinedToAuthor, + isRead: false, + createdAt: testDate, + chosenPlanId: 1, + dietId: 1, + planName: 'Plan', + userName: '', + consultantName: 'Consultant', + dietName: 'Test Diet', + role: 'consultant', + peerSlug: 'client-slug', + peerImagePath: '', + ); + + await tester.pumpWidget(createTestWidget(notification)); + await tester.pumpAndSettle(); + + expect( + find.text('Вы предложили персональную диету пользователю Пользователь, выбранная диета: «Test Diet»'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'displays PersonalDietIsDefinedToAuthorNotification without diet name', + (tester) async { + final notification = PersonalDietIsDefinedToAuthorNotification( + type: NotificationType.personalDietIsDefinedToAuthor, + isRead: false, + createdAt: testDate, + chosenPlanId: 1, + dietId: 1, + planName: 'Plan', + userName: 'Client', + consultantName: 'Consultant', + dietName: '', + role: 'consultant', + peerSlug: 'client-slug', + peerImagePath: '', + ); + + await tester.pumpWidget(createTestWidget(notification)); + await tester.pumpAndSettle(); + + expect( + find.text('Вы предложили персональную диету пользователю Client'), + findsOneWidget, + ); + }, + ); + testWidgets('displays UnknownNotification', (tester) async { final notification = UnknownNotification( type: NotificationType.unknown, diff --git a/test/features/plans/models/chosen_plan_model_test.dart b/test/features/plans/models/chosen_plan_model_test.dart index 5f53ba22..8dfee56b 100644 --- a/test/features/plans/models/chosen_plan_model_test.dart +++ b/test/features/plans/models/chosen_plan_model_test.dart @@ -200,6 +200,66 @@ void main() { }); }); + group('chosenOption', () { + const plan = 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, + packet1Options: PacketOptions(items: ['one'], price: 100, short: 'packet-1', hasPersonalDiet: false), + packet2Options: PacketOptions(items: ['two'], price: 200, short: 'packet-2', hasPersonalDiet: true), + packet3Options: PacketOptions(items: ['three'], price: 300, short: 'packet-3', hasPersonalDiet: false), + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + isEntitySubscribedByMe: null, + authorPlaceInRating: null, + ), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + isFavorite: false, + minPriceOfOptions: 100, + maxPriceOfOptions: 300, + wasLikedByMe: false, + ); + + ChosenPlanModel modelWith(String optionType) => ChosenPlanModel( + id: 1, + optionType: optionType, + startDate: DateTime(2024, 1, 1), + chosenPlanState: ChosenPlanStateEnum.stateInited, + chatId: 'chat-1', + consultantId: 1, + plan: plan, + ); + + test('возвращает первую опцию для options1', () { + expect(modelWith('options1').chosenOption?.short, 'packet-1'); + }); + + test('возвращает вторую опцию для options2', () { + expect(modelWith('options2').chosenOption?.short, 'packet-2'); + }); + + test('возвращает третью опцию для options3', () { + expect(modelWith('options3').chosenOption?.short, 'packet-3'); + }); + + test('возвращает null для неизвестного значения', () { + expect(modelWith('unknown').chosenOption, isNull); + expect(modelWith('1').chosenOption, isNull); + expect(modelWith('').chosenOption, isNull); + }); + }); + group('parseChosenPlan', () { test('parseChosenPlan создает ChosenPlanModel из Map', () { final json = {