From 2d7407ce9973ba93de77af35bbe8139c19d777ca Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Wed, 28 Jan 2026 17:32:29 +0300 Subject: [PATCH] feat: introduce My Diets and Create Diet features with associated UI, state management, and data handling. --- .../presentation/create_diet_screen.dart | 34 ----- .../presentation/cubit/create_plan_cubit.dart | 8 +- .../presentation/widgets/option_widget.dart | 2 +- .../feed/models/plan_pagination_model.dart | 2 +- .../presentation/my_diets_screen.dart | 12 +- .../presentation/widgets/my_diet_widget.dart | 134 +++++++----------- .../presentation/widgets/my_diets_tab.dart | 51 +++---- .../repositories/my_diets_repository.dart | 23 +-- .../personal_diet_suggestion_screen.dart | 51 ++++--- pubspec.yaml | 2 +- 10 files changed, 118 insertions(+), 201 deletions(-) diff --git a/lib/features/create_diet/presentation/create_diet_screen.dart b/lib/features/create_diet/presentation/create_diet_screen.dart index 95775e86..b77ac3b9 100644 --- a/lib/features/create_diet/presentation/create_diet_screen.dart +++ b/lib/features/create_diet/presentation/create_diet_screen.dart @@ -180,40 +180,6 @@ class _CreateDietScreenContentState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( children: [ - CustomOutlinedButton( - onPressed: () async { - context.read().setDietState(DietState.draft); - if (widget.diet != null) { - final result = await context.read().updateDiet(widget.diet!.id); - if (result == 'error') { - if (!context.mounted) return; - showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}'); - } - } else { - final result = await context.read().createDiet(); - if (result == 'error') { - if (!context.mounted) return; - showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); - } - } - }, - // onPressed: state.isDietPublishing - // ? () {} - // : () async { - // final diet = await context.read().getDietForPreview(); - // if (diet == null) return; - // if (!context.mounted) return; - // GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: diet); - // }, - text: "В черновик", - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), - ), - ), - const SizedBox(height: 12), CustomOutlinedButton( onPressed: state.isDietPublishing ? () {} diff --git a/lib/features/create_plan/presentation/cubit/create_plan_cubit.dart b/lib/features/create_plan/presentation/cubit/create_plan_cubit.dart index 2289b31f..21b07b68 100644 --- a/lib/features/create_plan/presentation/cubit/create_plan_cubit.dart +++ b/lib/features/create_plan/presentation/cubit/create_plan_cubit.dart @@ -341,7 +341,7 @@ class CreatePlanCubit extends Cubit { bool validateSelectedOptionDescription() { final option = state.planData.options[state.selectedOptionIndex ?? 0]; final optionsValidationsStates = [...state.validationState.optionsValidationState]; - if (option.description.length < 3) { + if (option.description == null || option.description!.length < 3) { optionsValidationsStates[state.selectedOptionIndex ?? 0] = optionsValidationsStates[state.selectedOptionIndex ?? 0].copyWith(descriptionHasError: true); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); return false; @@ -356,7 +356,7 @@ class CreatePlanCubit extends Cubit { final optionsValidationsStates = [...state.validationState.optionsValidationState]; bool result = true; - if (options[0].description.length < 3) { + if (options[0].description == null || options[0].description!.length < 3) { optionsValidationsStates[0] = optionsValidationsStates[0].copyWith(descriptionHasError: true); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); result = false; @@ -365,7 +365,7 @@ class CreatePlanCubit extends Cubit { emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); } if (state.selectedOptionIndex == null) return result; - if (options[1].description.length < 3) { + if (options[1].description == null || options[1].description!.length < 3) { optionsValidationsStates[1] = optionsValidationsStates[1].copyWith(descriptionHasError: true); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); result = false; @@ -374,7 +374,7 @@ class CreatePlanCubit extends Cubit { emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); } - if (options[2].description.length < 3) { + if (options[2].description == null || options[2].description!.length < 3) { optionsValidationsStates[2] = optionsValidationsStates[2].copyWith(descriptionHasError: true); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); result = false; diff --git a/lib/features/create_plan/presentation/widgets/option_widget.dart b/lib/features/create_plan/presentation/widgets/option_widget.dart index 24cde52b..cc4eb816 100644 --- a/lib/features/create_plan/presentation/widgets/option_widget.dart +++ b/lib/features/create_plan/presentation/widgets/option_widget.dart @@ -94,7 +94,7 @@ class _OptionWidgetState extends State { final price = double.tryParse(customerPriceController.text.replaceAll('₽', '')); creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽'; firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short; - firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description; + firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? ''; return MainBackgroundBouble( padding: const EdgeInsets.all(16), child: Column( diff --git a/lib/features/feed/models/plan_pagination_model.dart b/lib/features/feed/models/plan_pagination_model.dart index 6c948685..073021a7 100644 --- a/lib/features/feed/models/plan_pagination_model.dart +++ b/lib/features/feed/models/plan_pagination_model.dart @@ -66,7 +66,7 @@ abstract class PacketOptions with _$PacketOptions { @JsonKey(name: "items", fromJson: _parseItems) required List items, @JsonKey(name: "price") required dynamic price, @JsonKey(name: "short") required String short, - @JsonKey(name: "description_md") required String description, + @JsonKey(name: "description_md") String? description, @JsonKey(name: "has_personal_diet", fromJson: _parseHasPersonalDietField) required bool hasPersonalDiet, }) = _PacketOptions; diff --git a/lib/features/my_diets/presentation/my_diets_screen.dart b/lib/features/my_diets/presentation/my_diets_screen.dart index 59c9dd09..99d39b07 100644 --- a/lib/features/my_diets/presentation/my_diets_screen.dart +++ b/lib/features/my_diets/presentation/my_diets_screen.dart @@ -60,8 +60,7 @@ class MyDietsScreenContent extends StatelessWidget { builder: (context, state) { return NestedScrollView( controller: switch (state.selectedStateIndex) { - 1 => draftDietsScrollController, - 2 => personalDietsScrollController, + 1 => personalDietsScrollController, _ => activeDietsScrollController }, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { @@ -103,8 +102,7 @@ class MyDietsScreenContent extends StatelessWidget { backgroundColor: appTheme.whiteA700, borderRadius: 100, searchController: switch (state.selectedStateIndex) { - 1 => draftDietsSearchController, - 2 => personalDietsSearchController, + 1 => personalDietsSearchController, _ => activeDietsSearchController }, onChanged: (query) { @@ -146,8 +144,7 @@ class MyDietsScreenContent extends StatelessWidget { return DataErrorWidget(onReload: () => context.read().loadAllDiets()); } else { final gotDietLoadingError = state.activeDietsError != null && state.selectedStateIndex == 0 || - state.draftDietsError != null && state.selectedStateIndex == 1 || - state.personalDietsError != null && state.selectedStateIndex == 2; + state.personalDietsError != null && state.selectedStateIndex == 1; if (gotDietLoadingError) { return DataErrorWidget(onReload: () => context.read().loadCurrentDiets()); @@ -158,8 +155,7 @@ class MyDietsScreenContent extends StatelessWidget { } final diets = switch (state.selectedStateIndex) { - 1 => state.draftDiets, - 2 => state.personalDiets, + 1 => state.personalDiets, _ => state.activeDiets, }; diff --git a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart index f2f5892e..f2fccd5c 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -5,18 +5,15 @@ 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/enums/product_type.dart'; -import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/info_row.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; -import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart'; import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_cubit.dart'; import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_state.dart'; import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_deleted_bottom_sheet.dart'; -import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_drafetd_bottom_sheet.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -103,43 +100,56 @@ class _MyDietWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( children: [ + // Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных) Row( children: [ + // Кнопка удаления (слева) + CustomOutlinedButton( + onPressed: () async { + final result = await context.read().deleteDiet(widget.diet.id); + if (result) { + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + child: DietDeletedBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + } + }, + height: 52, + width: 52, + leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + const SizedBox(width: 6), + // Кнопка редактирования (центр) Expanded( child: CustomOutlinedButton( onPressed: () async { if (state.selectedStateIndex == 1) { - final result = await context.read().publishDiet(widget.diet.id); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - context: context, - child: SuccessPublishedDietOrPlanBottomSheet(title: widget.diet.name), - ); - await context.read().loadAllDiets(); - } else { - showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); - } - } else if (state.selectedStateIndex == 2) { - GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id); + await showErrorDialog(context, title: 'Предупреждение', description: 'Вы можете создать новую диету на основе существующей.'); + AppRouter.navigatorKey.currentContext?.pushNamed(CreateDietScreen.name, extra: widget.diet); } else { GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet); } }, - text: switch (state.selectedStateIndex) { - 1 => s.publish, - 2 => s.toOffer, - _ => s.edit, - }, + text: s.edit, height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), buttonStyle: ButtonStyle( shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), side: const WidgetStatePropertyAll(BorderSide.none), ), ), ), + // Кнопка поделиться (справа) для опубликованных if (state.selectedStateIndex == 0) ...[ const SizedBox(width: 6), CustomOutlinedButton( @@ -171,69 +181,23 @@ class _MyDietWidgetState extends State { ], ], ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: CustomOutlinedButton( - onPressed: () async { - if (state.selectedStateIndex == 0) { - final result = await context.read().unpublishDiet(widget.diet.id); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - context: context, - height: 250, - child: DietDraftedBottomSheet(title: widget.diet.name), - ); - await context.read().loadAllDiets(); - } else { - showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); - } - } else { - await showErrorDialog(context, title: 'Предупреждение', description: 'Вы можете создать новую диету на основе существующей.'); - AppRouter.navigatorKey.currentContext?.pushNamed(CreateDietScreen.name, extra: widget.diet); - } - }, - text: switch (state.selectedStateIndex) { - 0 => s.removeFromActive, - _ => s.edit, - }, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), - side: const WidgetStatePropertyAll(BorderSide.none), - ), - ), + // Нижний ряд: Предложить (только для персональных) + if (state.selectedStateIndex == 1) ...[ + const SizedBox(height: 8), + CustomOutlinedButton( + onPressed: () { + GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id); + }, + text: s.toOffer, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + side: const WidgetStatePropertyAll(BorderSide.none), ), - const SizedBox(width: 6), - CustomOutlinedButton( - onPressed: () async { - final result = await context.read().deleteDiet(widget.diet.id); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - context: context, - child: DietDeletedBottomSheet(title: widget.diet.name), - ); - await context.read().loadAllDiets(); - } - }, - height: 52, - width: 52, - leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), - side: const WidgetStatePropertyAll(BorderSide.none), - ), - text: '', - ), - ], - ), + ), + ], const SizedBox(height: 8), ], ), diff --git a/lib/features/my_diets/presentation/widgets/my_diets_tab.dart b/lib/features/my_diets/presentation/widgets/my_diets_tab.dart index a653441c..be6a0d95 100644 --- a/lib/features/my_diets/presentation/widgets/my_diets_tab.dart +++ b/lib/features/my_diets/presentation/widgets/my_diets_tab.dart @@ -21,7 +21,6 @@ class _MyDietsTabState extends State { final s = S.of(context); if (tabTitles.isEmpty) { tabTitles.add(s.published); - tabTitles.add(s.drafted); tabTitles.add(s.personal); } super.didChangeDependencies(); @@ -31,37 +30,29 @@ class _MyDietsTabState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Align( - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Container( + height: 48, + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: BorderRadius.circular(100), + ), + padding: const EdgeInsets.all(4), child: Row( children: [ - Container( - width: 484, - height: 48, - decoration: BoxDecoration( - color: appTheme.whiteA700, - borderRadius: BorderRadius.circular(100), - ), - padding: const EdgeInsets.all(4), - child: Row( - children: [ - ...List.generate( - 3, - (index) => Expanded( - child: AnimatedOptionButton( - textStyle: TextStyles.textSMedium, - isSelected: state.selectedStateIndex == index, - text: tabTitles[index], - onTap: () { - widget.additionalOnTap?.call(); - context.read().changeSelectedStateIndex(index); - }, - ), - ), - ), - ], + ...List.generate( + 2, + (index) => Expanded( + child: AnimatedOptionButton( + textStyle: TextStyles.textSMedium, + isSelected: state.selectedStateIndex == index, + text: tabTitles[index], + onTap: () { + widget.additionalOnTap?.call(); + context.read().changeSelectedStateIndex(index); + }, + ), ), ), ], diff --git a/lib/features/my_plans/repositories/my_diets_repository.dart b/lib/features/my_plans/repositories/my_diets_repository.dart index 1ee9afb1..aeb284a7 100644 --- a/lib/features/my_plans/repositories/my_diets_repository.dart +++ b/lib/features/my_plans/repositories/my_diets_repository.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart'; @@ -14,24 +16,11 @@ class MyPlansRepository { } } - Future getPlans({ - int page = 1, - String? query, - String? types, - int? duration, - int? minPrice, - int? maxPrice, - }) async { + Future getPlans({int page = 1, String? query, String? types, int? duration, int? minPrice, int? maxPrice}) async { try { - return (await myPlansService.getPlans( - page: page, - query: query, - types: types, - duration: duration, - minPrice: minPrice, - maxPrice: maxPrice, - )); - } catch (e) { + return (await myPlansService.getPlans(page: page, query: query, types: types, duration: duration, minPrice: minPrice, maxPrice: maxPrice)); + } catch (e, st) { + log(e.toString(), error: e, stackTrace: st); rethrow; } } diff --git a/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart b/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart index dad76406..32398019 100644 --- a/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart +++ b/lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart @@ -41,27 +41,38 @@ class PersonalDietSuggestionScreenContent extends StatelessWidget { ), body: state.isLoading ? const Center(child: Loader()) - : SingleChildScrollView( - child: Column( - children: [ - ...List.generate(state.consultationsWithDiet.length, (index) { - return ConsultationWidget( - consultationModel: state.consultationsWithDiet[index], - dietName: state.dietName, - ); - }), - const SizedBox( - height: 12, + : (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, + ), ), - ...List.generate(state.consultationsWithoutDiet.length, (index) { - return ConsultationWidget( - consultationModel: state.consultationsWithoutDiet[index], - dietName: state.dietName, - ); - }) - ], - ), - ), + ) + : SingleChildScrollView( + child: Column( + children: [ + ...List.generate(state.consultationsWithDiet.length, (index) { + return ConsultationWidget( + consultationModel: state.consultationsWithDiet[index], + dietName: state.dietName, + ); + }), + const SizedBox( + height: 12, + ), + ...List.generate(state.consultationsWithoutDiet.length, (index) { + return ConsultationWidget( + consultationModel: state.consultationsWithoutDiet[index], + dietName: state.dietName, + ); + }) + ], + ), + ), ); }, ); diff --git a/pubspec.yaml b/pubspec.yaml index 9a17e842..31bede1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.13+94 +version: 1.3.14+95 publish_to: 'none' environment: