feat(chat): add consultation progress and personal diet status

related to #1115
This commit is contained in:
2026-11-03 14:45:44 +03:00
parent 53ce73b41e
commit bd1155233f
35 changed files with 1999 additions and 265 deletions

View File

@@ -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()),

View File

@@ -48,6 +48,7 @@ class WsManager {
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? _chosenPlanStateChangedStream;
Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
@@ -74,6 +75,9 @@ class WsManager {
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
Stream<ChannelReadEvent>? 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<void> 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;
}
}

View File

@@ -31,7 +31,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
StreamSubscription<ChannelReadEvent>? typingSubscription;
StreamSubscription<ChannelReadEvent>? messagesSubscription;
StreamSubscription<ChannelReadEvent>? _chosenPlanStateSubscription;
StreamSubscription<void>? _reconnectSubscription;
bool _isUpdatingChosenPlan = false;
int currentPage = 1;
late int lastPage;
bool isLoading = false;
@@ -171,6 +173,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
log(e.toString());
typingSubscription?.cancel();
messagesSubscription?.cancel();
_chosenPlanStateSubscription?.cancel();
_reconnectSubscription?.cancel();
_reconnectSubscription = null;
wsManager.unsubscribeDirectChatChannel();
@@ -260,19 +263,30 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
Future<void> 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<ChatRoomState> {
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<ChatRoomState> {
),
);
});
_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<ChatRoomState> {
Future<void> 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<ChatRoomState> {
_timer?.cancel();
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
await _chosenPlanStateSubscription?.cancel();
await _reconnectSubscription?.cancel();
super.close();
}

View File

@@ -11,7 +11,6 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
@@ -24,11 +23,14 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
import 'package:eatvio_app/features/chat/presentation/utils/chat_banner_resolver.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
@@ -63,8 +65,6 @@ class _ChatScreenState extends State<ChatScreen> {
final TextEditingController textEditingController = TextEditingController();
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
final List<int> unreadMessageIds = [];
final showableChatBadgeStates =
ChosenPlanStateEnum.getShowableChatBadgeStates();
late AppLifecycleListener _listener;
List<File> pickedFiles = [];
@@ -188,49 +188,23 @@ class _ChatScreenState extends State<ChatScreen> {
ChosenPlanStateEnum.statePendingPayment,
ChosenPlanStateEnum.stateInCoordination,
].contains(chosenPlan?.chosenPlanState);
PacketOptions? chosenOption;
if (chosenPlan?.optionType == 'options1') {
chosenOption = chosenPlan?.plan.packet1Options;
} else if (chosenPlan?.optionType == 'options2') {
chosenOption = chosenPlan?.plan.packet2Options;
} else if (chosenPlan?.optionType == 'options3') {
chosenOption = chosenPlan?.plan.packet3Options;
}
final showAboutOrderButton =
state.chatRoom.chosenPlan != null &&
state.chatRoom.chosenPlan!.chosenPlanState !=
final chosenOption = chosenPlan?.chosenOption;
final progress = chosenPlan == null
? null
: mapConsultationProgress(
state: chosenPlan.chosenPlanState,
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
);
final bannerType = resolveChatBanner(
progress: progress,
amIConsultant: amIConsultant,
hasCard: state.hasCard,
state: chosenPlan?.chosenPlanState,
);
final showInfoButton =
chosenPlan != null &&
chosenPlan.chosenPlanState !=
ChosenPlanStateEnum.stateCanceledByTheInitiator;
BannerType? bannerType;
if (chosenPlan == null ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
bannerType = null;
} else if (amIConsultant &&
!state.hasCard &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.card;
} else if (amIConsultant &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.date;
} else if (!amIConsultant &&
(chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.statePendingPayment)) {
bannerType = BannerType.payment;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
amIConsultant) {
bannerType = BannerType.collectCharge;
} else if (chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateLastDayFinished &&
!amIConsultant) {
bannerType = BannerType.finishPlan;
} else if (chosenOption?.hasPersonalDiet == true &&
amIConsultant &&
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
bannerType = BannerType.setPersonalDiet;
}
return Scaffold(
resizeToAvoidBottomInset: true,
@@ -340,6 +314,28 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
),
],
),
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},
);
},
),
),
],
),
),

View File

@@ -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;
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -142,12 +142,37 @@ class _NotificationBannerState extends State<NotificationBanner> {
BannerType.setPersonalDiet => [
SizedBox(
width: size.width * 0.6,
child: Text(s.createPersonalDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
child: Text(s.suggestDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(MyDietsScreen.name);
GoRouter.of(context).pushNamed(
MyDietsScreen.name,
queryParameters: {'initialTabIndex': '2'},
);
},
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
),
const SizedBox(height: 16),
],
BannerType.changePersonalDiet => [
SizedBox(
width: size.width * 0.6,
child: Text(s.change_personal_diet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 8),
SizedBox(
width: size.width * 0.6,
child: Text(s.personal_diet_replaces_tomorrow, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
MyDietsScreen.name,
queryParameters: {'initialTabIndex': '2'},
);
},
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
),
@@ -240,4 +265,4 @@ class _StarIcon extends StatelessWidget {
}
}
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet }
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet, changePersonalDiet }

View File

@@ -20,7 +20,7 @@ abstract class ConsultationModel with _$ConsultationModel {
@JsonKey(name: 'user_profile_photo_url') required String userProfilePhotoUrl,
@JsonKey(name: 'consultation_option_price') required int consultationOptionPrice,
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
@JsonKey(name: 'end_date', fromJson: parseDate) required DateTime endDate,
@JsonKey(name: 'end_date', fromJson: parseDateOrNull) DateTime? endDate,
@JsonKey(name: 'consultant_id') required int consultantId,
required List<DiaryModel> diaries,
@JsonKey(name: 'personal_diet_id') required int? personalDietId,
@@ -40,6 +40,11 @@ abstract class DiaryModel with _$DiaryModel {
factory DiaryModel.fromJson(Map<String, dynamic> 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();

View File

@@ -0,0 +1,31 @@
enum ConsultationStep { startDate, paid, inProgress, finished }
enum PersonalDietStatus { notRequired, notProposed, proposed }
enum ConsultationPhase { active, canceled, paymentFailed }
class ConsultationProgress {
const ConsultationProgress({
required this.completedSteps,
required this.showProgressPlank,
required this.dietStatus,
required this.phase,
});
final Set<ConsultationStep> completedSteps;
final bool showProgressPlank;
final PersonalDietStatus dietStatus;
final ConsultationPhase phase;
bool isStepCompleted(ConsultationStep step) => completedSteps.contains(step);
bool get hasDietChip => dietStatus != PersonalDietStatus.notRequired;
bool get hasStatusChip => phase != ConsultationPhase.active;
bool get hasChip => hasDietChip || hasStatusChip;
bool get isCanceled => phase == ConsultationPhase.canceled;
bool get isPaymentFailed => phase == ConsultationPhase.paymentFailed;
}

View File

@@ -85,6 +85,25 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
if (widget.consultationsModel.personalDietId != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: appTheme.transparentGreen,
borderRadius: BorderRadius.circular(100),
),
child: Text(
s.personal_diet_proposed,
style: TextStyles.textXSSemibold.copyWith(
color: appTheme.greenA700,
),
),
),
],
],
),
],

View File

@@ -0,0 +1,111 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ConsultationProgressPlank extends StatelessWidget {
const ConsultationProgressPlank({super.key, required this.progress});
final ConsultationProgress progress;
static const _steps = [
ConsultationStep.startDate,
ConsultationStep.paid,
ConsultationStep.inProgress,
ConsultationStep.finished,
];
@override
Widget build(BuildContext context) {
if (!progress.showProgressPlank) {
return const SizedBox.shrink();
}
return Column(
children: [
Row(
children: [
for (var index = 0; index < _steps.length; index++) ...[
_ProgressStepCircle(
index: index,
isCompleted: progress.isStepCompleted(_steps[index]),
),
if (index != _steps.length - 1)
Expanded(
child: Container(
height: 2,
color: progress.isStepCompleted(_steps[index])
? appTheme.greenA700
: appTheme.lightGray00,
),
),
],
],
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final step in _steps)
Expanded(
child: Text(
_stepLabel(S.of(context), step),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyles.textFS10FW500.copyWith(
color: progress.isStepCompleted(step)
? appTheme.black
: appTheme.gray,
),
),
),
],
),
],
);
}
String _stepLabel(S s, ConsultationStep step) {
switch (step) {
case ConsultationStep.startDate:
return s.startDateHasBeenSet;
case ConsultationStep.paid:
return s.order_paid;
case ConsultationStep.inProgress:
return s.order_in_progress;
case ConsultationStep.finished:
return s.done;
}
}
}
class _ProgressStepCircle extends StatelessWidget {
const _ProgressStepCircle({
required this.index,
required this.isCompleted,
});
final int index;
final bool isCompleted;
@override
Widget build(BuildContext context) {
return Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted ? appTheme.greenA700 : appTheme.lightGray00,
),
child: isCompleted
? Icon(Icons.check, size: 14, color: appTheme.whiteA700)
: Text(
'${index + 1}',
style: TextStyles.textFS10FW500.copyWith(color: appTheme.gray),
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class PersonalDietStatusChip extends StatelessWidget {
const PersonalDietStatusChip({super.key, required this.progress});
final ConsultationProgress progress;
@override
Widget build(BuildContext context) {
if (!progress.hasChip) {
return const SizedBox.shrink();
}
final s = S.of(context);
final (label, background, foreground) = _chipStyle(s);
return Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(100),
),
child: Text(
label,
style: TextStyles.textXSSemibold.copyWith(color: foreground),
),
),
);
}
(String, Color, Color) _chipStyle(S s) {
if (progress.isCanceled) {
return (s.orderCanceled, appTheme.lightRed, appTheme.red);
}
if (progress.isPaymentFailed) {
return (s.payment_failed, appTheme.lightRed, appTheme.red);
}
if (progress.dietStatus == PersonalDietStatus.proposed) {
return (
s.personal_diet_proposed,
appTheme.transparentGreen,
appTheme.greenA700,
);
}
return (
s.personal_diet_not_proposed,
appTheme.gray100,
appTheme.gray,
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
ConsultationProgress mapConsultationProgress({
required ChosenPlanStateEnum state,
required bool hasPersonalDiet,
}) {
return ConsultationProgress(
completedSteps: _completedSteps(state),
showProgressPlank: _showProgressPlank(state),
dietStatus: _dietStatus(state, hasPersonalDiet),
phase: _phase(state),
);
}
Set<ConsultationStep> _completedSteps(ChosenPlanStateEnum state) {
switch (state) {
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateConfirmedByConsultant:
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
return const {};
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaymentFailed:
return const {ConsultationStep.startDate};
case ChosenPlanStateEnum.statePaid:
case ChosenPlanStateEnum.personalDietSet:
return const {ConsultationStep.startDate, ConsultationStep.paid};
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
case ChosenPlanStateEnum.stateLastDayStarted:
case ChosenPlanStateEnum.stateLastDayFinished:
case ChosenPlanStateEnum.stateComplained:
return const {
ConsultationStep.startDate,
ConsultationStep.paid,
ConsultationStep.inProgress,
};
case ChosenPlanStateEnum.stateDone:
case ChosenPlanStateEnum.stateChargeWasCollected:
return const {
ConsultationStep.startDate,
ConsultationStep.paid,
ConsultationStep.inProgress,
ConsultationStep.finished,
};
}
}
bool _showProgressPlank(ChosenPlanStateEnum state) {
switch (state) {
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateConfirmedByConsultant:
case ChosenPlanStateEnum.personalDietSet:
case ChosenPlanStateEnum.stateChargeWasCollected:
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
case ChosenPlanStateEnum.statePaymentFailed:
return false;
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaid:
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
case ChosenPlanStateEnum.stateLastDayStarted:
case ChosenPlanStateEnum.stateLastDayFinished:
case ChosenPlanStateEnum.stateComplained:
case ChosenPlanStateEnum.stateDone:
return true;
}
}
PersonalDietStatus _dietStatus(
ChosenPlanStateEnum state,
bool hasPersonalDiet,
) {
if (!hasPersonalDiet) {
return PersonalDietStatus.notRequired;
}
switch (state) {
case ChosenPlanStateEnum.personalDietSet:
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
case ChosenPlanStateEnum.stateLastDayStarted:
case ChosenPlanStateEnum.stateLastDayFinished:
case ChosenPlanStateEnum.stateComplained:
case ChosenPlanStateEnum.stateDone:
case ChosenPlanStateEnum.stateChargeWasCollected:
return PersonalDietStatus.proposed;
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateConfirmedByConsultant:
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaid:
return PersonalDietStatus.notProposed;
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
case ChosenPlanStateEnum.statePaymentFailed:
return PersonalDietStatus.notRequired;
}
}
ConsultationPhase _phase(ChosenPlanStateEnum state) {
switch (state) {
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
return ConsultationPhase.canceled;
case ChosenPlanStateEnum.statePaymentFailed:
return ConsultationPhase.paymentFailed;
default:
return ConsultationPhase.active;
}
}

View File

@@ -136,6 +136,11 @@ class MyDietsCubit extends Cubit<MyDietsState> {
}
}
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) {

View File

@@ -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<MyDietsCubit>()..loadAllDiets(), child: MyDietsScreenContent());
return BlocProvider(
create: (context) =>
sl<MyDietsCubit>()
..selectInitialTab(initialTabIndex)
..loadAllDiets(),
child: MyDietsScreenContent(),
);
}
}

View File

@@ -9,38 +9,50 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
final DietRepository dietRepository;
final PersonalDietSuggestionRepository personalDietSuggestionService;
late final int dietId;
late int dietId;
PersonalDietSuggestionCubit({required this.dietRepository, required this.personalDietSuggestionService}) : super(PersonalDietSuggestionState());
Future<void> setDietName(int dietId) async {
try {
emit(state.copyWith(isLoading: true, error: null));
if (!isClosed) {
emit(state.copyWith(isLoading: true, error: null));
}
final diet = await dietRepository.getDiet(dietId);
emit(state.copyWith(isLoading: false, dietName: diet.name));
if (!isClosed) {
emit(state.copyWith(isLoading: false, dietName: diet.name));
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
emit(state.copyWith(isLoading: false, error: e.toString()));
if (!isClosed) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
}
Future<void> getConsultationWithPersonalDiets(int dietId) async {
try {
this.dietId = dietId;
emit(state.copyWith(isLoading: true, error: null));
setDietName(dietId);
if (!isClosed) {
emit(state.copyWith(isLoading: true, error: null));
}
await setDietName(dietId);
final consultations = await personalDietSuggestionService.getConsultationWithPersonalDiets();
final consultationWithDiet = consultations.where((consultation) => consultation.personalDietId != null).toList();
final consultationWithoutDiet = consultations.where((consultation) => consultation.personalDietId == null).toList();
emit(state.copyWith(
isLoading: false,
consultationsWithDiet: consultationWithDiet,
consultationsWithoutDiet: consultationWithoutDiet,
));
if (!isClosed) {
emit(state.copyWith(
isLoading: false,
consultationsWithDiet: consultationWithDiet,
consultationsWithoutDiet: consultationWithoutDiet,
));
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
emit(state.copyWith(isLoading: false, error: e.toString()));
if (!isClosed) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
}
@@ -49,6 +61,7 @@ class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
}) async {
try {
await personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId);
await getConsultationWithPersonalDiets(dietId);
return true;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -62,6 +75,7 @@ class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
}) async {
try {
await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId);
await getConsultationWithPersonalDiets(dietId);
return true;
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/consultations/models/consultation_model.dart
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -64,7 +65,11 @@ class ConsultationWidget extends StatelessWidget {
),
),
Text(
'Старт: ${consultationModel.userName}',
s.start_date_label(
DateFormat('dd.MM.yyyy').format(
consultationModel.startDate,
),
),
style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F),
),
@@ -130,7 +135,7 @@ class ConsultationWidget extends StatelessWidget {
onTap: () async {
final result = await context
.read<PersonalDietSuggestionCubit>()
.attachDietToConsultation(
.reattachDietToConsultation(
consultationId: consultationModel.id,
);
AppRouter.navigatorKey.currentContext?.pop();

View File

@@ -11,13 +11,23 @@ class PersonalDietSuggestionRepository {
}
Future<void> attachPersonalDiet({required int consultationId, required int dietId}) async {
return personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId);
Future<void> attachPersonalDiet({
required int consultationId,
required int dietId,
}) async {
await personalDietSuggestionService.attachPersonalDiet(
consultationId,
{'diet_id': dietId},
);
}
Future<void> reattachPersonalDiet({required int consultationId, required int dietId}) async {
return await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId);
Future<void> reattachPersonalDiet({
required int consultationId,
required int dietId,
}) async {
await personalDietSuggestionService.reattachPersonalDiet(
consultationId,
{'diet_id': dietId},
);
}
}

View File

@@ -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<ConsultationPaginationModel> 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<void> 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<ConsultationPaginationModel> getConsultationWithPersonalDiets({
@Query('filters[plan][has_personal_diet][\$personal_diet]')
String hasPersonalDiet = '1',
@Query('filters[last_state][\$notIn][]')
List<String> notIn = const [
'done',
'charge_was_collected',
'complained',
'last_day_finished',
'last_day_started',
'canceled_by_the_initiator',
'inited',
],
@Query('sort[]') List<String> sort = const ['start_date:desc'],
});
Future<void> 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<void> attachPersonalDiet(
@Path('id') int consultationId,
@Body() Map<String, dynamic> body,
);
@POST('/api/chosen_plans/{id}/re_attach_diet')
Future<void> reattachPersonalDiet(
@Path('id') int consultationId,
@Body() Map<String, dynamic> body,
);
}

View File

@@ -21,6 +21,21 @@ abstract class ChosenPlanModel with _$ChosenPlanModel {
factory ChosenPlanModel.fromJson(Map<String, dynamic> json) => _$ChosenPlanModelFromJson(json);
}
extension ChosenPlanModelX on ChosenPlanModel {
PacketOptions? get chosenOption {
switch (optionType) {
case 'options1':
return plan.packet1Options;
case 'options2':
return plan.packet2Options;
case 'options3':
return plan.packet3Options;
default:
return null;
}
}
}
ChosenPlanModel? parseChosenPlan(dynamic json) {
if (json is Map<String, dynamic>) {
return ChosenPlanModel.fromJson(json);

View File

@@ -30,24 +30,26 @@ class MessageLookup extends MessageLookupByLibrary {
static String m3(percent, amount) =>
"Discount ${percent}% — you save ${amount} ₽";
static String m4(step) => "шаг ${step} из 2";
static String m4(date) => "Start: ${date}";
static String m5(planName, name) =>
static String m5(step) => "шаг ${step} из 2";
static String m6(planName, name) =>
"Завтра начинается план «${planName}» с пользователем ${name}";
static String m6(planName) =>
static String m7(planName) =>
"Завтра начнется прохождение плана «${planName}»";
static String m7(name, planName) =>
static String m8(name, planName) =>
"Пользователь ${name} отменил подписку на план «${planName}»";
static String m8(name, planName) =>
static String m9(name, planName) =>
"Пользователь ${name} оплатил план «${planName}»";
static String m9(name, planName) =>
static String m10(name, planName) =>
"Пользователь ${name} подписался на план «${planName}»";
static String m10(planName) => "Вы отменили подписку на план «${planName}»";
static String m11(planName) => "Вы отменили подписку на план «${planName}»";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -170,6 +172,9 @@ class MessageLookup extends MessageLookupByLibrary {
),
"carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"),
"changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"),
"change_personal_diet": MessageLookupByLibrary.simpleMessage(
"Change personal diet",
),
"chargeApproved": MessageLookupByLibrary.simpleMessage("Выплата одобрена"),
"chargeCollectionFailed": MessageLookupByLibrary.simpleMessage(
"Не удалось оформить выплату",
@@ -635,6 +640,9 @@ class MessageLookup extends MessageLookupByLibrary {
"order": MessageLookupByLibrary.simpleMessage("Порядок"),
"orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"),
"order_created": MessageLookupByLibrary.simpleMessage("Заказ создан"),
"order_in_progress": MessageLookupByLibrary.simpleMessage(
"Order in progress",
),
"order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"),
"order_price": MessageLookupByLibrary.simpleMessage("Сумма заказа"),
"order_status": MessageLookupByLibrary.simpleMessage("Статус заказа"),
@@ -665,6 +673,15 @@ class MessageLookup extends MessageLookupByLibrary {
"personalDietCreating": MessageLookupByLibrary.simpleMessage(
"Составление индивидуальной диеты",
),
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
"Personal diet not proposed",
),
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
"Personal diet proposed",
),
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
"The change takes effect tomorrow",
),
"personal_diet_set": MessageLookupByLibrary.simpleMessage(
"Персональная диета установлена",
),
@@ -821,12 +838,13 @@ class MessageLookup extends MessageLookupByLibrary {
"Выбрана дата старта",
),
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
"start_date_label": m4,
"statistic": MessageLookupByLibrary.simpleMessage("Статистика"),
"step": MessageLookupByLibrary.simpleMessage("Шаг"),
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
"Пошаговый фоторецепт",
),
"step_widget": m4,
"step_widget": m5,
"successfully_published": MessageLookupByLibrary.simpleMessage(
"успешно опубликован",
),
@@ -851,8 +869,8 @@ class MessageLookup extends MessageLookupByLibrary {
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
"tommorowPlanWithUserWillStart": m5,
"tomorrowYourPlanWillStart": m6,
"tommorowPlanWithUserWillStart": m6,
"tomorrowYourPlanWillStart": m7,
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
@@ -886,7 +904,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Верхние показатели давления:",
),
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
"userCanceldThePlanSubscription": m7,
"userCanceldThePlanSubscription": m8,
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
),
@@ -894,8 +912,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана.",
),
"userPaidThePlan": m8,
"userSubscribedToPlan": m9,
"userPaidThePlan": m9,
"userSubscribedToPlan": m10,
"user_authorization": MessageLookupByLibrary.simpleMessage(
"Аутентификация",
),
@@ -1025,7 +1043,7 @@ class MessageLookup extends MessageLookupByLibrary {
"youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу",
),
"youCanceledSubscriptionToPlan": m10,
"youCanceledSubscriptionToPlan": m11,
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
"Вы уверены, что хотите отменить заказ?",
),

View File

@@ -23,6 +23,8 @@ class MessageLookup extends MessageLookupByLibrary {
static String m3(percent, amount) =>
"Скидка ${percent}% — вы экономите ${amount} ₽";
static String m4(date) => "Старт: ${date}";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
@@ -66,6 +68,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Если вы любите мучные продукты, но хотите придерживаться диеты, вам подходит диета с добавленим мучных блюд на основе цельнозерновых вариантов. В них больше сложных углеводов и клетчатки, что способствует длительному чувству сытости и поддерживает здоровье пищеварительной системы.",
),
"carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"),
"changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"),
"change_personal_diet": MessageLookupByLibrary.simpleMessage(
"Заменить диету",
),
"chat": MessageLookupByLibrary.simpleMessage("Чат"),
"chest": MessageLookupByLibrary.simpleMessage("Грудь"),
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
@@ -78,6 +84,10 @@ class MessageLookup extends MessageLookupByLibrary {
"choose_plan_duration": MessageLookupByLibrary.simpleMessage(
"Выберите срок плана",
),
"choose_plan_start_date_with_personal_diet":
MessageLookupByLibrary.simpleMessage(
"Выберите дату старта плана питания с учетом составления персональной диеты",
),
"choose_start_diet_date": MessageLookupByLibrary.simpleMessage(
"Выберите дату старты диеты. Она отобразиться у Вас в дневнике",
),
@@ -126,6 +136,7 @@ class MessageLookup extends MessageLookupByLibrary {
"do_you_take_pills": MessageLookupByLibrary.simpleMessage(
"Принимаете ли вы лекарства или бады?",
),
"done": MessageLookupByLibrary.simpleMessage("Завершен"),
"down_pressure": MessageLookupByLibrary.simpleMessage(
"Нижние показатели давления:",
),
@@ -192,6 +203,9 @@ class MessageLookup extends MessageLookupByLibrary {
"from_a_to_z": MessageLookupByLibrary.simpleMessage("Название: А-Я"),
"from_z_to_a": MessageLookupByLibrary.simpleMessage("Название: Я-А"),
"gallery": MessageLookupByLibrary.simpleMessage("Галерея"),
"goToMyDiets": MessageLookupByLibrary.simpleMessage(
"Перейти к моим диетам",
),
"gr": MessageLookupByLibrary.simpleMessage("гр"),
"grams": MessageLookupByLibrary.simpleMessage("гр"),
"have_promo_code": MessageLookupByLibrary.simpleMessage(
@@ -284,6 +298,9 @@ class MessageLookup extends MessageLookupByLibrary {
),
"option_title": MessageLookupByLibrary.simpleMessage("Заголовок опции"),
"order": MessageLookupByLibrary.simpleMessage("Порядок"),
"orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"),
"order_in_progress": MessageLookupByLibrary.simpleMessage("Заказ в работе"),
"order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"),
"password": MessageLookupByLibrary.simpleMessage("Пароль"),
"password_confirmation": MessageLookupByLibrary.simpleMessage(
"Подтвердите пароль",
@@ -297,8 +314,21 @@ class MessageLookup extends MessageLookupByLibrary {
"password_short": MessageLookupByLibrary.simpleMessage(
"В пароле должно быть минимум 6 символов",
),
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
"personal": MessageLookupByLibrary.simpleMessage("Персональные"),
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
"Диета не предложена",
),
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
"Диета предложена",
),
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
"Замена действует со следующего дня",
),
"personal_diet_set": MessageLookupByLibrary.simpleMessage(
"Персональная диета установлена",
),
"personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"),
"photo": MessageLookupByLibrary.simpleMessage("Фото"),
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
@@ -364,7 +394,12 @@ class MessageLookup extends MessageLookupByLibrary {
"План питания для похудения должен быть разработан с учётом индивидуальных особенностей организма, возраста, пола, уровня физической активности и состояния здоровья. Общие принципы: \n● Снижение калорийности; \n● Сбалансированный рацион; \n● Разнообразие продуктов; \n● Питьевой режим; \n● Ограничение вредных продуктов;",
),
"standard": MessageLookupByLibrary.simpleMessage("Стандарт"),
"startDateHasBeenSet": MessageLookupByLibrary.simpleMessage(
"Выбрана дата старта",
),
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
"start_date_label": m4,
"suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"),
"tell_your_schedule": MessageLookupByLibrary.simpleMessage(
"Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:",
),

View File

@@ -4443,6 +4443,66 @@ class S {
args: [],
);
}
/// `Order in progress`
String get order_in_progress {
return Intl.message(
'Order in progress',
name: 'order_in_progress',
desc: '',
args: [],
);
}
/// `Personal diet proposed`
String get personal_diet_proposed {
return Intl.message(
'Personal diet proposed',
name: 'personal_diet_proposed',
desc: '',
args: [],
);
}
/// `Personal diet not proposed`
String get personal_diet_not_proposed {
return Intl.message(
'Personal diet not proposed',
name: 'personal_diet_not_proposed',
desc: '',
args: [],
);
}
/// `Change personal diet`
String get change_personal_diet {
return Intl.message(
'Change personal diet',
name: 'change_personal_diet',
desc: '',
args: [],
);
}
/// `The change takes effect tomorrow`
String get personal_diet_replaces_tomorrow {
return Intl.message(
'The change takes effect tomorrow',
name: 'personal_diet_replaces_tomorrow',
desc: '',
args: [],
);
}
/// `Start: {date}`
String start_date_label(String date) {
return Intl.message(
'Start: $date',
name: 'start_date_label',
desc: '',
args: [date],
);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<S> {

View File

@@ -526,6 +526,17 @@
"video_call_close": "Close",
"video_call_notification_in_progress": "Call in progress",
"video_call_notification_return": "Return to call",
"video_call_notification_end": "End call"
"video_call_notification_end": "End call",
"order_in_progress": "Order in progress",
"personal_diet_proposed": "Personal diet proposed",
"personal_diet_not_proposed": "Personal diet not proposed",
"change_personal_diet": "Change personal diet",
"personal_diet_replaces_tomorrow": "The change takes effect tomorrow",
"start_date_label": "Start: {date}",
"@start_date_label": {
"placeholders": {
"date": {"type": "String"}
}
}
}

View File

@@ -271,5 +271,26 @@
"video_call_notification_in_progress": "Звонок идёт",
"video_call_notification_return": "Вернуться к звонку",
"video_call_notification_end": "Завершить звонок",
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px"
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
"startDateHasBeenSet": "Выбрана дата старта",
"order_paid": "Заказ оплачен",
"done": "Завершен",
"orderCanceled": "Заказ отменен",
"payment_failed": "Оплата не прошла",
"personal_diet_set": "Персональная диета установлена",
"suggestDiet": "Предложить диету",
"changeDiet": "Заменить диету",
"goToMyDiets": "Перейти к моим диетам",
"choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты",
"order_in_progress": "Заказ в работе",
"personal_diet_proposed": "Диета предложена",
"personal_diet_not_proposed": "Диета не предложена",
"change_personal_diet": "Заменить диету",
"personal_diet_replaces_tomorrow": "Замена действует со следующего дня",
"start_date_label": "Старт: {date}",
"@start_date_label": {
"placeholders": {
"date": {"type": "String"}
}
}
}

View File

@@ -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<ChannelReadEvent> chosenPlanStream,
}) {
when(mockWsManager.client).thenReturn(MockPusherChannelsClient());
when(
mockWsManager.directChatStream,
).thenAnswer((_) => const Stream<ChannelReadEvent>.empty());
when(
mockWsManager.clientTypingStream,
).thenAnswer((_) => const Stream<ChannelReadEvent>.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<ChannelReadEvent>.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<ChatRoomStateLoaded>());
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller.add(_FakeChannelReadEvent());
await Future<void>.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<ChannelReadEvent>.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<void>.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<ChannelReadEvent>.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<ChatRoomStateLoaded>());
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller
..add(_FakeChannelReadEvent())
..add(_FakeChannelReadEvent());
await Future<void>.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;
}

View File

@@ -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,
);
});
});
}

View File

@@ -0,0 +1,82 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: Center(child: child)),
);
}
void main() {
group('ChatConsultationHeader', () {
testWidgets('вызывает onInfoTap по тапу на info', (tester) async {
var tapped = 0;
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateInited,
hasPersonalDiet: false,
);
await tester.pumpWidget(
_wrap(
ChatConsultationHeader(
progress: progress,
showInfoButton: true,
onInfoTap: () => tapped++,
),
),
);
await tester.tap(find.byIcon(Icons.info_outline_rounded));
await tester.pump();
expect(tapped, 1);
});
testWidgets(
'при showProgressPlank=false и proposed видны заголовок и чип, плашки нет',
(tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isFalse);
expect(progress.dietStatus, PersonalDietStatus.proposed);
await tester.pumpWidget(
_wrap(
ChatConsultationHeader(
progress: progress,
showInfoButton: false,
onInfoTap: () {},
),
),
);
final s = S.of(tester.element(find.byType(ChatConsultationHeader)));
expect(find.text(s.more_about_order), findsOneWidget);
expect(find.byType(PersonalDietStatusChip), findsOneWidget);
expect(find.text(s.personal_diet_proposed), findsOneWidget);
expect(find.byType(ConsultationProgressPlank), findsNothing);
expect(find.byIcon(Icons.check), findsNothing);
},
);
});
}

View File

@@ -0,0 +1,71 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: Center(child: child)),
);
}
void main() {
group('ConsultationProgressPlank', () {
testWidgets('не строит шаги при showProgressPlank=false', (tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isFalse);
await tester.pumpWidget(
_wrap(ConsultationProgressPlank(progress: progress)),
);
expect(
find.descendant(
of: find.byType(ConsultationProgressPlank),
matching: find.byType(SizedBox),
),
findsOneWidget,
);
expect(find.byIcon(Icons.check), findsNothing);
expect(find.byType(Text), findsNothing);
});
testWidgets(
'рисует 4 шага с иконками и подписями при showProgressPlank=true',
(tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateDone,
hasPersonalDiet: false,
);
expect(progress.showProgressPlank, isTrue);
await tester.pumpWidget(
_wrap(ConsultationProgressPlank(progress: progress)),
);
final s = S.of(tester.element(find.byType(ConsultationProgressPlank)));
expect(find.byIcon(Icons.check), findsNWidgets(4));
expect(find.text(s.startDateHasBeenSet), findsOneWidget);
expect(find.text(s.order_paid), findsOneWidget);
expect(find.text(s.order_in_progress), findsOneWidget);
expect(find.text(s.done), findsOneWidget);
},
);
});
}

View File

@@ -0,0 +1,88 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: Center(child: child)),
);
}
Widget _chip(ChosenPlanStateEnum state, {bool hasPersonalDiet = true}) {
return PersonalDietStatusChip(
progress: mapConsultationProgress(
state: state,
hasPersonalDiet: hasPersonalDiet,
),
);
}
void main() {
group('PersonalDietStatusChip', () {
testWidgets('notRequired + active ничего не показывает', (tester) async {
await tester.pumpWidget(
_wrap(_chip(ChosenPlanStateEnum.stateInited, hasPersonalDiet: false)),
);
expect(
find.descendant(
of: find.byType(PersonalDietStatusChip),
matching: find.byType(SizedBox),
),
findsOneWidget,
);
expect(find.byType(Text), findsNothing);
});
testWidgets('proposed показывает personal_diet_proposed', (tester) async {
await tester.pumpWidget(_wrap(_chip(ChosenPlanStateEnum.stateStarted)));
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.personal_diet_proposed), findsOneWidget);
});
testWidgets('notProposed показывает personal_diet_not_proposed', (
tester,
) async {
await tester.pumpWidget(_wrap(_chip(ChosenPlanStateEnum.statePaid)));
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.personal_diet_not_proposed), findsOneWidget);
});
testWidgets('canceled показывает orderCanceled', (tester) async {
await tester.pumpWidget(
_wrap(_chip(ChosenPlanStateEnum.stateCanceledByTheInitiator)),
);
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.orderCanceled), findsOneWidget);
});
testWidgets('paymentFailed показывает payment_failed', (tester) async {
await tester.pumpWidget(
_wrap(_chip(ChosenPlanStateEnum.statePaymentFailed)),
);
final s = S.of(tester.element(find.byType(PersonalDietStatusChip)));
expect(find.text(s.payment_failed), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,279 @@
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:flutter_test/flutter_test.dart';
typedef _Expected = ({
Set<ConsultationStep> 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<ChosenPlanStateEnum, _Expected> _expectations = {
ChosenPlanStateEnum.stateInited: (
steps: {},
plank: false,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateStartDateHasBeenSet: (
steps: {_startDate},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateInCoordination: (
steps: {},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateStarted: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateInProgress: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.statePendingPayment: (
steps: {_startDate},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateConfirmedByConsultant: (
steps: {},
plank: false,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.statePaid: (
steps: {_startDate, _paid},
plank: true,
diet: PersonalDietStatus.notProposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateCanceledByTheInitiator: (
steps: {},
plank: false,
diet: PersonalDietStatus.notRequired,
phase: ConsultationPhase.canceled,
),
ChosenPlanStateEnum.stateLastDayStarted: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateLastDayFinished: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateComplained: (
steps: {_startDate, _paid, _inProgress},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateDone: (
steps: {_startDate, _paid, _inProgress, _finished},
plank: true,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.personalDietSet: (
steps: {_startDate, _paid},
plank: false,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.stateChargeWasCollected: (
steps: {_startDate, _paid, _inProgress, _finished},
plank: false,
diet: PersonalDietStatus.proposed,
phase: ConsultationPhase.active,
),
ChosenPlanStateEnum.statePaymentFailed: (
steps: {_startDate},
plank: false,
diet: PersonalDietStatus.notRequired,
phase: ConsultationPhase.paymentFailed,
),
};
void main() {
group('mapConsultationProgress — все состояния', () {
test('таблица покрывает ровно все 16 состояний', () {
expect(_expectations.length, 16);
expect(_expectations.keys.toSet(), ChosenPlanStateEnum.values.toSet());
});
for (final entry in _expectations.entries) {
test('${entry.key.name} маппится во все поля ConsultationProgress', () {
final progress = mapConsultationProgress(
state: entry.key,
hasPersonalDiet: true,
);
expect(progress.completedSteps, unorderedEquals(entry.value.steps));
expect(progress.showProgressPlank, entry.value.plank);
expect(progress.dietStatus, entry.value.diet);
expect(progress.phase, entry.value.phase);
});
}
});
group('mapConsultationProgress — ключевые сценарии', () {
test('in_coordination показывает плашку без завершённых шагов', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateInCoordination,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isTrue);
expect(progress.completedSteps, isEmpty);
});
test(
'personal_diet_setted не показывает плашку и завершает startDate+paid',
() {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
);
expect(progress.showProgressPlank, isFalse);
expect(progress.completedSteps, unorderedEquals([_startDate, _paid]));
},
);
test(
'started/in_progress/last_day_started/last_day_finished/complained — 3 шага',
() {
const states = [
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
ChosenPlanStateEnum.stateLastDayStarted,
ChosenPlanStateEnum.stateLastDayFinished,
ChosenPlanStateEnum.stateComplained,
];
for (final state in states) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: true,
);
expect(
progress.completedSteps,
unorderedEquals([_startDate, _paid, _inProgress]),
reason: 'state=${state.name}',
);
}
},
);
test('done/charge_was_collected — 4 шага', () {
const states = [
ChosenPlanStateEnum.stateDone,
ChosenPlanStateEnum.stateChargeWasCollected,
];
for (final state in states) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: true,
);
expect(
progress.completedSteps,
unorderedEquals([_startDate, _paid, _inProgress, _finished]),
reason: 'state=${state.name}',
);
}
});
test(
'canceled — фаза canceled и dietStatus notRequired даже при диете',
() {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateCanceledByTheInitiator,
hasPersonalDiet: true,
);
expect(progress.phase, ConsultationPhase.canceled);
expect(progress.isCanceled, isTrue);
expect(progress.dietStatus, PersonalDietStatus.notRequired);
},
);
test('payment_failed — фаза paymentFailed и только startDate', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.statePaymentFailed,
hasPersonalDiet: true,
);
expect(progress.phase, ConsultationPhase.paymentFailed);
expect(progress.isPaymentFailed, isTrue);
expect(progress.completedSteps, unorderedEquals([_startDate]));
});
test('hasPersonalDiet=false даёт notRequired для любого состояния', () {
for (final state in ChosenPlanStateEnum.values) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: false,
);
expect(
progress.dietStatus,
PersonalDietStatus.notRequired,
reason: 'state=${state.name}',
);
expect(progress.hasDietChip, isFalse, reason: 'state=${state.name}');
}
});
test('paid с диетой даёт notProposed', () {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.statePaid,
hasPersonalDiet: true,
);
expect(progress.dietStatus, PersonalDietStatus.notProposed);
});
test('personal_diet_setted/started/proposed дают proposed', () {
const states = [
ChosenPlanStateEnum.personalDietSet,
ChosenPlanStateEnum.stateStarted,
];
for (final state in states) {
final progress = mapConsultationProgress(
state: state,
hasPersonalDiet: true,
);
expect(
progress.dietStatus,
PersonalDietStatus.proposed,
reason: 'state=${state.name}',
);
}
});
});
}

View File

@@ -216,6 +216,8 @@ void main() {
consultationId: consultationId,
dietId: dietId,
)).called(1);
verify(mockPersonalDietSuggestionRepository.getConsultationWithPersonalDiets()).called(2);
verify(mockDietRepository.getDiet(dietId)).called(2);
});
test('reattachDietToConsultation - успешное переприкрепление диеты', () async {
@@ -265,6 +267,8 @@ void main() {
consultationId: consultationId,
dietId: dietId,
)).called(1);
verify(mockPersonalDietSuggestionRepository.getConsultationWithPersonalDiets()).called(2);
verify(mockDietRepository.getDiet(dietId)).called(2);
});
});
}

View File

@@ -231,6 +231,54 @@ void main() {
});
});
testWidgets('shows success bottom sheet after successful diet reattachment', (tester) async {
await mockNetworkImagesFor(() async {
final cubit = MockPersonalDietSuggestionCubit();
final consultationWithDiet = ConsultationModel(
id: 1,
chosenPlanState: ChosenPlanStateEnum.stateInProgress,
planTitle: 'Test Plan',
userName: 'Test User',
userSlug: 'test-user',
consultantPayment: 1000.0,
duration: 7,
currentDiaryDay: 3,
userProfilePhotoUrl: '/avatar.jpg',
consultationOptionPrice: 500,
startDate: DateTime(2025, 1, 1),
endDate: DateTime(2025, 1, 8),
consultantId: 1,
diaries: [],
personalDietId: 1,
);
when(cubit.state).thenReturn(PersonalDietSuggestionState());
when(cubit.stream).thenAnswer((_) => const Stream.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.reattachDietToConsultation(consultationId: 1)).thenAnswer((_) async => true);
await tester.pumpWidget(
BlocProvider<PersonalDietSuggestionCubit>.value(
value: cubit,
child: wrap(ConsultationWidget(consultationModel: consultationWithDiet, dietName: 'Test Diet')),
),
);
await tester.pumpAndSettle();
final changeButton = find.text(S.of(tester.element(find.byType(ConsultationWidget))).changeDiet);
await tester.tap(changeButton);
await tester.pumpAndSettle();
final offerButton = find.text(S.of(tester.element(find.byType(ConsultationWidget))).toOffer);
await tester.tap(offerButton);
await tester.pumpAndSettle();
expect(find.text('«Test Diet» предложена пользователю Test User'), findsOneWidget);
verify(cubit.reattachDietToConsultation(consultationId: 1)).called(1);
verifyNever(cubit.attachDietToConsultation(consultationId: 1));
});
});
testWidgets('shows failure bottom sheet after failed diet attachment', (tester) async {
await mockNetworkImagesFor(() async {
final cubit = MockPersonalDietSuggestionCubit();
@@ -279,7 +327,7 @@ void main() {
expect(find.text('Test User'), findsOneWidget);
expect(find.text('Test Plan'), findsOneWidget);
expect(find.text('Старт: Test User'), findsOneWidget);
expect(find.text('Старт: 01.01.2025'), findsOneWidget);
});
});
@@ -318,7 +366,7 @@ void main() {
expect(find.text('Test User'), findsOneWidget);
expect(find.text('Test Plan'), findsOneWidget);
expect(find.text('Старт: Test User'), findsNothing);
expect(find.text('Старт: 01.01.2025'), findsNothing);
});
});
});

View File

@@ -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);
});
});
}

View File

@@ -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<Map<String, dynamic>>(any)).thenAnswer((invocation) async {
capturedRequest = invocation.positionalArguments.first as RequestOptions;
return Response<Map<String, dynamic>>(
requestOptions: capturedRequest,
statusCode: 200,
data: mockData,
);
});
final result = await service.getConsultationWithPersonalDiets();
expect(result, isA<ConsultationPaginationModel>());
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<Map<String, dynamic>>(any)).called(1);
});
test('getConsultationWithPersonalDiets - пробрасывает ошибку', () async {
when(mockDio.get(
any,
queryParameters: anyNamed('queryParameters'),
)).thenThrow(DioException(
when(mockDio.fetch<Map<String, dynamic>>(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<void>(any)).thenAnswer((invocation) async {
capturedRequest = invocation.positionalArguments.first as RequestOptions;
return Response<void>(
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<void>(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<void>(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<DioException>()),
);
});
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<void>(any)).thenAnswer((invocation) async {
capturedRequest = invocation.positionalArguments.first as RequestOptions;
return Response<void>(
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<void>(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<void>(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<DioException>()),
);
});