Merge remote-tracking branch 'upstream/main' into issue-1123

This commit is contained in:
misha
2026-11-11 10:49:33 +03:00
59 changed files with 3234 additions and 494 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,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<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 +187,24 @@ 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,
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<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

@@ -146,12 +146,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)),
),
@@ -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 }

View File

@@ -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<DiaryModel> diaries,
@JsonKey(name: 'personal_diet_id') required int? personalDietId,
@JsonKey(name: 'personal_diet_name') String? personalDietName,
}) = _ConsultationModel;
factory ConsultationModel.fromJson(Map<String, dynamic> json) => _$ConsultationModelFromJson(json);
@@ -40,6 +41,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,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<ConsultationStep> 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;
}

View File

@@ -85,6 +85,30 @@ 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(
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,
),
),
),
],
],
),
],

View File

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

View File

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

View File

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

View File

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

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

@@ -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<String, dynamic> 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,

View File

@@ -277,6 +277,12 @@ class NotificationsCubit extends Cubit<NotificationsState> {
isRead: isRead,
createdAt: createdAt,
),
NotificationType.personalDietIsDefinedToAuthor => PersonalDietIsDefinedToAuthorNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson,
type,

View File

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

View File

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

View File

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

View File

@@ -9,38 +9,54 @@ 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,
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<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 +65,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 +79,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

@@ -9,8 +9,8 @@ abstract class PersonalDietSuggestionState with _$PersonalDietSuggestionState {
@Default([]) List<ConsultationModel> consultationsWithDiet,
@Default([]) List<ConsultationModel> consultationsWithoutDiet,
@Default('') String dietName,
@Default(0) int dietDuration,
@Default(false) bool isLoading,
@Default(null) String? error,
}) = _PersonalDietSuggestionState;
}
}

View File

@@ -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<PersonalDietSuggestionCubit>()
.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<PersonalDietSuggestionCubit>()
.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<void> _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<PersonalDietSuggestionCubit>();
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<SuggestDietBottomSheet> {
}
}
class SuggestionSuccessBottomSheet extends StatefulWidget {
const SuggestionSuccessBottomSheet({
super.key,
required this.userName,
required this.dietName,
});
final String userName;
final String dietName;
@override
State<SuggestionSuccessBottomSheet> createState() =>
_SuggestionSuccessBottomSheet();
}
class _SuggestionSuccessBottomSheet
extends State<SuggestionSuccessBottomSheet> {
@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,

View File

@@ -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<PersonalDietSuggestionCubit>()..getConsultationWithPersonalDiets(dietId),
create: (context) =>
sl<PersonalDietSuggestionCubit>()
..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<PersonalDietSuggestionCubit, PersonalDietSuggestionState>(
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),
),
),
],
),
);
}
}

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

@@ -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<ChosenPlanStateEnum> getActiveStates() {
return [
ChosenPlanStateEnum.statePaid,

View File

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

View File

@@ -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<String, dynamic> 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) {

View File

@@ -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<AboutPlanState> {
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<AboutPlanState> {
return false;
}
}
String _getOptionNameFromIndex(int index) => switch (index) {
1 => 'options2',
2 => 'options3',
_ => 'options1',
};
}

View File

@@ -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<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -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(
"Вы уверены, что хотите отменить заказ?",
),

View File

@@ -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<String, Function> _notInlinedMessages(_) => <String, Function>{
"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(
"Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:",
),

View File

@@ -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<S> {

View File

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

View File

@@ -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": "Нет активных консультаций с персональной диетой"
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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: 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, 'Индивидуальный план питания');
});
});
}

View File

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

View File

@@ -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 {

View File

@@ -92,6 +92,7 @@ void main() {
DiaryReminderForUsersNotification.fromJson(base, NotificationType.diaryReminderForUsers);
DiaryReminderForUsersNotification.fromJson({'created_at': c, 'data': <String, dynamic>{}}, 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);

View File

@@ -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',

View File

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

View File

@@ -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<List>());
expect(result['notifications'].length, 1);
expect(result['notifications'].first, isA<PersonalDietIsDefinedToCustomerNotification>());
});
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<List>());
expect(result['notifications'].length, 1);
expect(result['notifications'].first, isA<PersonalDietIsDefinedToAuthorNotification>());
});
test('getNotifications - обрабатывает UserDailyReportCompleted', () async {

View File

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

View File

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

View File

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

View File

@@ -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<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('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<void>.value());
when(cubit.attachDietToConsultation(consultationId: 1)).thenAnswer((_) async => true);
await tester.pumpWidget(
BlocProvider<PersonalDietSuggestionCubit>.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<void>.value());
await tester.pumpWidget(
BlocProvider<PersonalDietSuggestionCubit>.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<OutlinedButton>(find.byType(OutlinedButton));
expect(button.onPressed, isNull);
expect(
button.style?.backgroundColor?.resolve({WidgetState.disabled}),
appTheme.dividerColor,
);
expect(
tester.widget<Text>(find.text(s.suggestDiet)).style?.color,
appTheme.gray,
);
});
});
});

View File

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

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

View File

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

View File

@@ -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',