diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 7142360b..6eb6c2c7 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..5c8db98c 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,9 +23,11 @@ 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/user_details/presentation/user_details_screen.dart'; @@ -63,8 +64,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 +187,24 @@ 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, + personalDietName: chosenPlan.personalDietName, + ); + 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 7ec9c0bc..ab21fd5c 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -146,12 +146,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)), ), @@ -244,4 +269,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..0e321e2c 100644 --- a/lib/features/consultations/models/consultation_model.dart +++ b/lib/features/consultations/models/consultation_model.dart @@ -20,10 +20,11 @@ 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, + @JsonKey(name: 'personal_diet_name') String? personalDietName, }) = _ConsultationModel; factory ConsultationModel.fromJson(Map json) => _$ConsultationModelFromJson(json); @@ -40,6 +41,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..35f56f50 --- /dev/null +++ b/lib/features/consultations/models/consultation_progress.dart @@ -0,0 +1,33 @@ +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, + this.personalDietName, + }); + + final Set completedSteps; + final bool showProgressPlank; + final PersonalDietStatus dietStatus; + final ConsultationPhase phase; + final String? personalDietName; + + 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 e1a6da0a..7aa2c52d 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -85,6 +85,30 @@ 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( + 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/consultation_progress_plank.dart b/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart new file mode 100644 index 00000000..0ad10a62 --- /dev/null +++ b/lib/features/consultations/presentation/widgets/consultation_progress_plank.dart @@ -0,0 +1,123 @@ +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 Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + 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(_steps[index]) + ? 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({ + super.key, + 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..559d1f39 --- /dev/null +++ b/lib/features/consultations/presentation/widgets/personal_diet_status_chip.dart @@ -0,0 +1,61 @@ +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) { + final dietName = progress.personalDietName; + final label = dietName == null || dietName.isEmpty + ? s.personal_diet_proposed + : s.personal_diet_proposed_named(dietName); + return ( + label, + 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..c815c7a1 --- /dev/null +++ b/lib/features/consultations/utils/consultation_progress_mapper.dart @@ -0,0 +1,116 @@ +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, + String? personalDietName, +}) { + return ConsultationProgress( + completedSteps: _completedSteps(state), + showProgressPlank: _showProgressPlank(state), + dietStatus: _dietStatus(state, hasPersonalDiet), + phase: _phase(state), + personalDietName: personalDietName, + ); +} + +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.stateChargeWasCollected: + case ChosenPlanStateEnum.stateCanceledByTheInitiator: + case ChosenPlanStateEnum.statePaymentFailed: + return false; + case ChosenPlanStateEnum.stateInCoordination: + case ChosenPlanStateEnum.stateStartDateHasBeenSet: + case ChosenPlanStateEnum.statePendingPayment: + case ChosenPlanStateEnum.statePaid: + case ChosenPlanStateEnum.personalDietSet: + 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/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/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/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..2c6dfd2e 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,30 @@ class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget { } } +class _PersonalDietIsDefinedToAuthorWidget extends StatelessWidget { + const _PersonalDietIsDefinedToAuthorWidget({required this.notification}); + final PersonalDietIsDefinedToAuthorNotification notification; + @override + Widget build(BuildContext context) { + 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: notificationsText, + 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/order_info/presentation/info_by_order_screen.dart b/lib/features/order_info/presentation/info_by_order_screen.dart index c37913f5..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) { @@ -164,7 +156,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 +165,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/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart b/lib/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart index 0afb7d3e..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 @@ -9,38 +9,54 @@ 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, + dietDuration: diet.duration, + )); + } } 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 +65,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 +79,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/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 a9fcef6e..9244f588 100644 --- a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart +++ b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart @@ -4,6 +4,8 @@ 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/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'; @@ -11,6 +13,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'; @@ -19,9 +22,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,7 +73,20 @@ class ConsultationWidget extends StatelessWidget { ), ), Text( - 'Старт: ${consultationModel.userName}', + 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( + consultationModel.startDate, + ), + ), style: TextStyles.textMMedium.copyWith( color: const Color(0xFF88949F), ), @@ -103,15 +125,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, ), @@ -128,35 +182,8 @@ class ConsultationWidget extends StatelessWidget { userName: consultationModel.userName, dietName: dietName, onTap: () async { - final result = await context - .read() - .attachDietToConsultation( - 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); }, ), ); @@ -166,12 +193,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( @@ -183,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); }, ), ); @@ -220,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 { @@ -278,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/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/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/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/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 f70f1cd2..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, @@ -15,10 +17,24 @@ 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; factory ChosenPlanModel.fromJson(Map json) => _$ChosenPlanModelFromJson(json); + + PacketOptions? get chosenOption { + switch (PlanOptionType.tryParse(optionType)) { + case PlanOptionType.options1: + return plan.packet1Options; + case PlanOptionType.options2: + return plan.packet2Options; + case PlanOptionType.options3: + return plan.packet3Options; + case null: + return null; + } + } } ChosenPlanModel? parseChosenPlan(dynamic json) { 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 0c9de781..20a73529 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -52,32 +52,50 @@ class MessageLookup extends MessageLookupByLibrary { static String m13(period) => "Weight unchanged over ${period}"; - static String m14(planName) => "План «${planName}» начался"; + static String m14(userName) => + "You have proposed a personal diet to ${userName}"; - static String m15(planName, name) => + static String m15(userName, dietName) => + "You have proposed a personal diet to ${userName}, selected diet: «${dietName}»"; + + static String m16(duration, dayWord) => + "The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration."; + + static String m17(dietDuration, dietDayWord, planDuration, planDayWord) => + "Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}."; + + static String m18(name) => "Diet «${name}» proposed"; + + static String m19(planName) => "План «${planName}» начался"; + + static String m20(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m16(percent, amount) => + static String m21(duration, dayWord) => "Plan for ${duration} ${dayWord}"; + + static String m22(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m17(step) => "шаг ${step} из 2"; + static String m23(date) => "Start: ${date}"; - static String m18(planName, name) => + static String m24(step) => "шаг ${step} из 2"; + + static String m25(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m19(planName) => + static String m26(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m20(name, planName) => + static String m27(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m21(name, planName) => + static String m28(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m22(name, planName) => + static String m29(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m23(planName) => "Вы отменили подписку на план «${planName}»"; + static String m30(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -200,6 +218,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( "Не удалось оформить выплату", @@ -347,6 +368,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "days": MessageLookupByLibrary.simpleMessage("дней"), "deception": MessageLookupByLibrary.simpleMessage("Обман"), + "default_user_name": MessageLookupByLibrary.simpleMessage("User"), "delete": MessageLookupByLibrary.simpleMessage("Удалить"), "deleteComment": MessageLookupByLibrary.simpleMessage( "Удалить комментарий", @@ -754,6 +776,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( "У вас нет привязанных карт. Что бы начать работу, привяжите карту", ), @@ -785,6 +811,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("Статус заказа"), @@ -818,6 +847,20 @@ class MessageLookup extends MessageLookupByLibrary { "personalDietCreating": MessageLookupByLibrary.simpleMessage( "Составление индивидуальной диеты", ), + "personal_diet_defined_to_author": m14, + "personal_diet_defined_to_author_named": m15, + "personal_diet_duration_hint": m16, + "personal_diet_duration_mismatch": m17, + "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( + "Personal diet not proposed", + ), + "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( + "Personal diet proposed", + ), + "personal_diet_proposed_named": m18, + "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( + "The change takes effect tomorrow", + ), "personal_diet_set": MessageLookupByLibrary.simpleMessage( "Персональная диета установлена", ), @@ -834,17 +877,18 @@ class MessageLookup extends MessageLookupByLibrary { "Физическая форма", ), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), - "planStarted": m14, + "planStarted": m19, "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "Если вы не подтвердите, то план через три дня подтвердится автоматически", ), - "planWithUserStarted": m15, + "planWithUserStarted": m20, "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc_example": MessageLookupByLibrary.simpleMessage( "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), + "plan_duration_label": m21, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -881,7 +925,7 @@ class MessageLookup extends MessageLookupByLibrary { "Promo code applied!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), - "promo_discount_hint": m16, + "promo_discount_hint": m22, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), @@ -1002,12 +1046,13 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), + "start_date_label": m23, "statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "step": MessageLookupByLibrary.simpleMessage("Шаг"), "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m17, + "step_widget": m24, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -1032,8 +1077,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m18, - "tomorrowYourPlanWillStart": m19, + "tommorowPlanWithUserWillStart": m25, + "tomorrowYourPlanWillStart": m26, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -1067,7 +1112,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m20, + "userCanceldThePlanSubscription": m27, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -1075,8 +1120,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m21, - "userSubscribedToPlan": m22, + "userPaidThePlan": m28, + "userSubscribedToPlan": m29, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1206,7 +1251,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m23, + "youCanceledSubscriptionToPlan": m30, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 8a623abf..2a3fdec0 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -50,9 +50,27 @@ class MessageLookup extends MessageLookupByLibrary { static String m13(period) => "Вес не изменился за ${period}"; - static String m16(percent, amount) => + static String m14(userName) => + "Вы предложили персональную диету пользователю ${userName}"; + + static String m15(userName, dietName) => + "Вы предложили персональную диету пользователю ${userName}, выбранная диета: «${dietName}»"; + + static String m16(duration, dayWord) => + "Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью."; + + static String m17(dietDuration, dietDayWord, planDuration, planDayWord) => + "Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}."; + + static String m18(name) => "Диета «${name}» предложена"; + + static String m21(duration, dayWord) => "План на ${duration} ${dayWord}"; + + static String m22(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; + static String m23(date) => "Старт: ${date}"; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Moscow": MessageLookupByLibrary.simpleMessage("Москва"), @@ -96,6 +114,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("Обхват груди"), @@ -108,6 +130,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( "Выберите дату старты диеты. Она отобразиться у Вас в дневнике", ), @@ -140,6 +166,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("Диета"), @@ -156,6 +183,7 @@ class MessageLookup extends MessageLookupByLibrary { "do_you_take_pills": MessageLookupByLibrary.simpleMessage( "Принимаете ли вы лекарства или бады?", ), + "done": MessageLookupByLibrary.simpleMessage("Завершен"), "down_pressure": MessageLookupByLibrary.simpleMessage( "Нижние показатели давления:", ), @@ -222,6 +250,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( @@ -410,6 +441,10 @@ class MessageLookup extends MessageLookupByLibrary { ), "no": MessageLookupByLibrary.simpleMessage("Нет"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), + "no_active_personal_diet_consultations": + MessageLookupByLibrary.simpleMessage( + "Нет активных консультаций с персональной диетой", + ), "no_installed_banks": MessageLookupByLibrary.simpleMessage( "Нет установленных банков", ), @@ -432,6 +467,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( "Подтвердите пароль", @@ -448,8 +486,26 @@ class MessageLookup extends MessageLookupByLibrary { "pay_by_qr_code": MessageLookupByLibrary.simpleMessage( "Оплатить по QR-коду", ), + "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"), + "personal_diet_defined_to_author": m14, + "personal_diet_defined_to_author_named": m15, + "personal_diet_duration_hint": m16, + "personal_diet_duration_mismatch": m17, + "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( + "Диета не предложена", + ), + "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( + "Диета предложена", + ), + "personal_diet_proposed_named": m18, + "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( + "Замена действует со следующего дня", + ), + "personal_diet_set": MessageLookupByLibrary.simpleMessage( + "Персональная диета установлена", + ), "personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"), "photo": MessageLookupByLibrary.simpleMessage("Фото"), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), @@ -459,6 +515,7 @@ class MessageLookup extends MessageLookupByLibrary { "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), + "plan_duration_label": m21, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -486,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary { "Промокод применён!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), - "promo_discount_hint": m16, + "promo_discount_hint": m22, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), @@ -543,7 +600,12 @@ class MessageLookup extends MessageLookupByLibrary { "План питания для похудения должен быть разработан с учётом индивидуальных особенностей организма, возраста, пола, уровня физической активности и состояния здоровья. Общие принципы: \n● Снижение калорийности; \n● Сбалансированный рацион; \n● Разнообразие продуктов; \n● Питьевой режим; \n● Ограничение вредных продуктов;", ), "standard": MessageLookupByLibrary.simpleMessage("Стандарт"), + "startDateHasBeenSet": MessageLookupByLibrary.simpleMessage( + "Выбрана дата старта", + ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), + "start_date_label": m23, + "suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"), "tell_your_schedule": MessageLookupByLibrary.simpleMessage( "Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:", ), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 6d77b17b..4f787674 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -5208,6 +5208,149 @@ 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: [], + ); + } + + /// `Diet «{name}» proposed` + String personal_diet_proposed_named(String name) { + return Intl.message( + 'Diet «$name» proposed', + name: 'personal_diet_proposed_named', + desc: '', + args: [name], + ); + } + + /// `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( + '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], + ); + } + + /// `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 549c4569..94dde4f0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -210,27 +210,27 @@ "personal": "Персональные", "edit": "Редактировать", "toOffer": "Предложить", - "removeFromActive": "Снять с публикации", + "removeFromActive": "Снять с публикации", "diet": "Диета", "deleted": "Удалена", "my_plans": "Мои планы", - "from": "от", + "from": "от", "user_data": "Данные пользователя", "general_info": "Общая информация", "sex": "Пол", - "physical_fitness": "Физическая форма", + "physical_fitness": "Физическая форма", "height": "Рост", "pressure": "Давление", "upper": "Верхнее", "lower": "Нижнее", "has_hypo": "Склонность к гипотонии", - "has_hyper": "Склонность к гипертонии", + "has_hyper": "Склонность к гипертонии", "other_info": "Прочая информация", "has_alergy": "Наличие аллергии", "takes_pills": "Принимает лекарства", "sport_hobbies": "Спортивные увлечения", "liked_groceries": "Любимые продукты", - "not_liked_groceries": "Не любимые продукты", + "not_liked_groceries": "Не любимые продукты", "diet_started": "Диета уже начата", "cancel_diet": "Отменить диету?", "confirm": "Подтвердить", @@ -239,7 +239,7 @@ "hour": "час", "another_form_of_hour": "часа", "minutes": "минуты", - "minute": "минута", + "minute": "минута", "another_form_of_minute": "минут", "there_is_a_video": "Есть видео", "recipe_author": "Автор рецепта", @@ -323,8 +323,8 @@ "can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту", "add_card": "Привязать карту", "choose_plan_start_date": "Выберите дату старта плана питания", - "choose_date": "Выбрать дату", - "date_has_ben_set": "Дата старта начала консультации установлена", + "choose_date": "Выбрать дату", + "date_has_ben_set": "Дата старта начала консультации установлена", "tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации", "pay": "Оплатить", "choose_bank_for_payment": "Выберите банк", @@ -343,13 +343,13 @@ "sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка", "more_about_order": "Подробнее о заказе", "cancel_order": "Отменить заказ", - "refuse_order":"Отказаться от заказа", + "refuse_order": "Отказаться от заказа", "user_info": "Анкета пользователя", "pending_payment": "Ожидается оплата", "my_cards": "Мои карты", "new_card": "Добавить карту", "no_default_cards": "У вас нет привязанных карт. Что бы начать работу, привяжите карту", - "chose_plan_start_date_2":"Выберите дату старты плана. Она отобразиться в дневнике пользователя", + "chose_plan_start_date_2": "Выберите дату старты плана. Она отобразиться в дневнике пользователя", "plan_seller": "Продавец плана", "payment_failed": "Оплата не прошла", "order_paid": "Заказ оплачен", @@ -396,7 +396,7 @@ "chargeApproved": "Выплата одобрена", "moneySentToCard": "Средства отправлены на карту", "chargeCollectionFailed": "Не удалось оформить выплату", - "contactSupport": "Обратитесь в поддержку", + "contactSupport": "Обратитесь в поддержку", "atLeast3Symbols": "Минимум 3 символа", "chosePlanType": "Выберите тип плана", "minPlanPrice": "Минимальная сумма 1200₽", @@ -414,13 +414,13 @@ "tomorrowYourPlanWillStart": "Завтра начнется прохождение плана «{planName}»", "planStarted": "План «{planName}» начался", "userCanceldThePlanSubscription": "Пользователь {name} отменил подписку на план «{planName}»", - "userSubscribedToPlan": "Пользователь {name} подписался на план «{planName}»", + "userSubscribedToPlan": "Пользователь {name} подписался на план «{planName}»", "markAsRead": "Отметить всё прочитанным", "allowAcessToGalery": "Разрешите доступ к фото", "allowAcessToGaleryDescription": "Предоставление доступа позволит вам добавлять фото к записям", "allowAccessToCamera": "Разрешите доступ к камере", "allowAccessToCameraDescription": "Предоставление доступа позволит вам делать фотографии и добавлять к записям", - "allowAccess": "Разрешить доступ" , + "allowAccess": "Разрешить доступ", "copy": "Скопировать", "reply": "Ответить", "deleteComment": "Удалить комментарий", @@ -431,7 +431,7 @@ "choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты", "chosenPlanStartDateDescr": "Дату старта плана можно выбрать не ранее, чем через два. Такое ограничение дается на создание персональной диеты", "createPersonalDiet": "Составьте персональную диету", - "goToMyDiets": "Перейти к моим диетам", + "goToMyDiets": "Перейти к моим диетам", "accountDeleting": "Удаление аккаунта", "youShureYouWantToDeleteAccount": "Вы уверены, что хотите удалить аккаунт?", "itCantBeRecovered": "Его нельзя будет восстановить", @@ -439,11 +439,11 @@ "cannotDeleteAccount": "Не получилось удалить ваш аккаунт", "resend": "Отправить повторно", "notifications": "Уведомления", - "step_widget": "шаг {step} из 2", + "step_widget": "шаг {step} из 2", "your_name": "Ваше имя", "your_last_name": "Ваша фамилия", "type_email": "Введите email", - "phone": "Телефон", + "phone": "Телефон", "male_short": "М", "female_short": "Ж", "next": "Далее", @@ -456,19 +456,19 @@ "activity_second_value": "1,375 — низкий (тренировки не менее 20 мин 1–3 раза в неделю)", "activity_third_value": "1,55 — умеренный (тренировки 30–60 мин 3–4 раза в неделю)", "activity_fourth_value": "1,7 — высокий (тренировки 30–60 мин 5–7 раза в неделю; тяжёлая физическая работа)", - "activity_fifth_value": "1,9 — экстремальный (несколько интенсивных тренировок в день)", + "activity_fifth_value": "1,9 — экстремальный (несколько интенсивных тренировок в день)", "registration_completed": "Регистрация завершена!", "acc_has_been_created": "Ваш аккаунт создан. Для входа используйте этот пароль:", "password_desc": "Сохраните его в надежном месте! Вы сможете изменить пароль в настройках профиля", "acc_has_been_created_btn": "Войти в аккаунт", - "first_name_validation_error": "Имя обязательно к заполнению", + "first_name_validation_error": "Имя обязательно к заполнению", "last_name_error": "Фамилия обязательна для заполнения", "email_error": "Некорректный адрес электронной почты", "phone_error": "Некорректный номер телефона", "gender_error": "Выберите пол", "date_of_birth_error": "Некорректная дата рождения", "height_error": "Введите рост", - "weight_error": "Введите вес", + "weight_error": "Введите вес", "phisical_activity": "Уровень физической активности", "copied2": "Скопировано!", "login": "Войти в аккаунт", @@ -483,7 +483,7 @@ "letsInvite": "Давайте пригласим близких в итвио", "eatvioIsAHelthyHabbit": "Итвио — это полезная привычка", "invite": "Пригласить", - "comment_hint": "Комментарий", + "comment_hint": "Комментарий", "hide_photo_title": "Спрятать фото?", "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", "unhide_photo_title": "Показать фото?", @@ -520,8 +520,12 @@ "promo_discount_hint": "Discount {percent}% — you save {amount} ₽", "@promo_discount_hint": { "placeholders": { - "percent": {"type": "int"}, - "amount": {"type": "String"} + "percent": { + "type": "int" + }, + "amount": { + "type": "String" + } } }, "promo_remove_failed": "Could not remove the promo code. Try again later", @@ -730,5 +734,86 @@ "insights_next_day": "Next day", "insights_pick_date": "Pick a date", "insights_today": "Today", - "insights_save_error": "Failed to save the entry" + "insights_save_error": "Failed to save the entry", + "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" + } + } + }, + "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", + "start_date_label": "Start: {date}", + "@start_date_label": { + "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 44149e9b..2cc23e38 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -263,8 +263,12 @@ "promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽", "@promo_discount_hint": { "placeholders": { - "percent": {"type": "int"}, - "amount": {"type": "String"} + "percent": { + "type": "int" + }, + "amount": { + "type": "String" + } } }, "promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже", @@ -475,5 +479,96 @@ "insights_next_day": "Следующий день", "insights_pick_date": "Выбрать дату", "insights_today": "Сегодня", - "insights_save_error": "Не удалось сохранить запись" + "insights_save_error": "Не удалось сохранить запись", + "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_proposed_named": "Диета «{name}» предложена", + "@personal_diet_proposed_named": { + "placeholders": { + "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": "Замена действует со следующего дня", + "start_date_label": "Старт: {date}", + "@start_date_label": { + "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/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..887e593e --- /dev/null +++ b/test/features/chat/presentation/widgets/chat_consultation_header_test.dart @@ -0,0 +1,86 @@ +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( + 'personal_diet_setted показывает плашку и чип вместе с баннером замены', + (tester) async { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + personalDietName: 'Индивидуальный план питания', + ); + expect(progress.showProgressPlank, isTrue); + 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.order_in_progress), findsNWidgets(2)); + expect(find.byType(PersonalDietStatusChip), 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/consultation_progress_plank_test.dart b/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart new file mode 100644 index 00000000..6d50d483 --- /dev/null +++ b/test/features/consultations/presentation/widgets/consultation_progress_plank_test.dart @@ -0,0 +1,99 @@ +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.stateConfirmedByConsultant, + 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); + }, + ); + + 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/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..68ea86e4 --- /dev/null +++ b/test/features/consultations/presentation/widgets/personal_diet_status_chip_test.dart @@ -0,0 +1,111 @@ +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, + String? personalDietName, +}) { + return PersonalDietStatusChip( + progress: mapConsultationProgress( + state: state, + hasPersonalDiet: hasPersonalDiet, + personalDietName: personalDietName, + ), + ); +} + +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('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 { + 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..ac0ded6c --- /dev/null +++ b/test/features/consultations/utils/consultation_progress_mapper_test.dart @@ -0,0 +1,289 @@ +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: true, + 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, isTrue); + 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}', + ); + } + }); + + test('personalDietName передаётся в ConsultationProgress', () { + final progress = mapConsultationProgress( + state: ChosenPlanStateEnum.personalDietSet, + hasPersonalDiet: true, + personalDietName: 'Индивидуальный план питания', + ); + + expect(progress.personalDietName, 'Индивидуальный план питания'); + }); + }); +} 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/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..24d6ec92 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -1212,6 +1212,89 @@ 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.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/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/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/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..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); @@ -216,6 +219,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 +270,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/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 55b184b1..11b8d568 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'; @@ -34,6 +35,7 @@ void main() { final mockConsultation = ConsultationModel( id: 1, + chatId: 'chat-1', chosenPlanState: ChosenPlanStateEnum.stateInProgress, planTitle: 'Test Plan', userName: 'Test User', @@ -68,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'), + ); + }, + ), ], ), ); @@ -201,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(); @@ -226,7 +254,99 @@ 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('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', + 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('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); }); }); @@ -279,7 +399,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 +438,71 @@ 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); + }); + }); + + 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); + }); }); } 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()), ); }); 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(); diff --git a/test/features/plans/models/chosen_plan_model_test.dart b/test/features/plans/models/chosen_plan_model_test.dart index a3100298..8dfee56b 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', () { @@ -198,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 = { @@ -207,6 +269,7 @@ void main() { 'last_state': 'initied', 'chat_id': 'chat-1', 'consultant_id': 1, + 'personal_diet_name': 'Индивидуальный план питания', 'plan': { 'id': 1, 'slug': 'test-plan',