From cb312037b24102df2b9727265be4211f57ca5eea Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 12 Oct 2026 08:50:33 +0300 Subject: [PATCH] feat(UGPC): issue-777 --- lib/core/routing/other_routes.dart | 10 +- lib/core/widgets/custom_popup.dart | 121 ++++++++++++++-- .../success_published_diet_bottom_sheet.dart | 2 +- .../presentation/create_plan_screen.dart | 28 +++- .../presentation/cubit/create_plan_cubit.dart | 80 +++++++++-- .../widgets/plan_image_picker_widget.dart | 22 ++- .../repositories/create_plan_repository.dart | 10 +- .../services/create_plan_service.dart | 8 +- .../diary/presentation/diary_edit_access.dart | 2 +- .../presentation/widgets/plan_widget.dart | 1 + .../presentation/pages/about_plan_screen.dart | 6 + pubspec.yaml | 2 +- test/core/widgets/custom_popup_test.dart | 134 +++++++++++++++++- .../cubit/create_plan_cubit_test.dart | 47 ++++++ .../fixtures/doubles/create_plan_doubles.dart | 5 + 15 files changed, 442 insertions(+), 36 deletions(-) diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index a97124f9..96f5f2cb 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart'; import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart'; import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart'; @@ -183,7 +184,14 @@ class OtherRoutes { return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64); }, ), - GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()), + GoRoute( + path: RouteConstants.createPlanScreen, + name: 'createPlanScreen', + builder: (context, state) { + final plan = state.extra is PlanModel ? state.extra as PlanModel : null; + return CreatePlanScreen(plan: plan); + }, + ), GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()), GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()), GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()), diff --git a/lib/core/widgets/custom_popup.dart b/lib/core/widgets/custom_popup.dart index 1ec7184d..e0abdefe 100644 --- a/lib/core/widgets/custom_popup.dart +++ b/lib/core/widgets/custom_popup.dart @@ -6,12 +6,20 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; +import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart'; +import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart'; +import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; +import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; -import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; +import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; @@ -33,6 +41,7 @@ class CustomPopup extends StatelessWidget { final FeedBlogPostModel? feedBlogPostModel; final Recipe? recipe; final Diet? diet; + final PlanModel? plan; final String userSlug; final VoidCallback? onPostsChanged; @@ -47,6 +56,7 @@ class CustomPopup extends StatelessWidget { this.feedBlogPostModel, this.recipe, this.diet, + this.plan, required this.userSlug, this.onPostsChanged, }); @@ -86,7 +96,103 @@ class CustomPopup extends StatelessWidget { ); } - List _buildMenuItems(S s,BuildContext context) { + Future _openEditor(BuildContext context) async { + final router = GoRouter.of(context); + switch (complainedContentType) { + case ComplainedContent.posts: + await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); + onPostsChanged?.call(); + case ComplainedContent.recipe: + final currentRecipe = recipe; + if (currentRecipe == null) { + return; + } + final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe); + if (result != null && context.mounted) { + _reloadEditedContent(context); + } + case ComplainedContent.diet: + final currentDiet = diet; + if (currentDiet == null) { + return; + } + final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true}); + if (result == true && context.mounted) { + _reloadEditedContent(context); + } + case ComplainedContent.plan: + final currentPlan = plan; + if (currentPlan == null) { + return; + } + final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan); + if (result == true && context.mounted) { + _reloadEditedContent(context); + } + default: + return; + } + } + + void _reloadEditedContent(BuildContext context) { + switch (complainedContentType) { + case ComplainedContent.recipe: + final currentRecipe = recipe; + if (currentRecipe == null) { + return; + } + try { + context.read().getRecipe(currentRecipe.slug); + } catch (_) {} + try { + context.read().loadRecipesList(); + } catch (_) {} + case ComplainedContent.diet: + final currentDiet = diet; + if (currentDiet == null) { + return; + } + try { + context.read().getDiet(id: currentDiet.id, slug: currentDiet.slug); + } catch (_) {} + try { + context.read().loadDiets(); + } catch (_) {} + case ComplainedContent.plan: + final currentPlan = plan; + if (currentPlan == null) { + return; + } + try { + context.read().getPlan(slug: currentPlan.slug); + } catch (_) {} + try { + context.read().loadPlans(); + } catch (_) {} + try { + context.read().loadPlans(); + } catch (_) {} + default: + return; + } + } + + bool _showsEditAction() { + switch (complainedContentType) { + case ComplainedContent.posts: + return feedBlogPostModel != null || id != null; + case ComplainedContent.recipe: + return recipe != null; + case ComplainedContent.diet: + return diet != null; + case ComplainedContent.plan: + return plan != null; + default: + return false; + } + } + + List _buildMenuItems(S s, BuildContext context) { final entries = []; void addItem(String icon, String text, Function() onTap) { @@ -110,12 +216,9 @@ class CustomPopup extends StatelessWidget { ), ); }); - if (complainedContentType == ComplainedContent.posts) { - addItem('assets/icons/edit_paint.svg', 'Редактировать', () async { - if (complainedContentType == ComplainedContent.posts) { - await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); - onPostsChanged?.call(); - } + if (_showsEditAction()) { + addItem('assets/icons/edit_paint.svg', s.edit, () { + _openEditor(context); }); } addItem('assets/icons/red_trash.svg', 'Удалить', () async { diff --git a/lib/core/widgets/success_published_diet_bottom_sheet.dart b/lib/core/widgets/success_published_diet_bottom_sheet.dart index 5b5625b7..3794e5c0 100644 --- a/lib/core/widgets/success_published_diet_bottom_sheet.dart +++ b/lib/core/widgets/success_published_diet_bottom_sheet.dart @@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget { if (isDrafted == true) { text = 'cохранена в черновиках'; } else if (isUpdated == true) { - text = 'успешно обновлена'; + text = isPlan ? 'успешно обновлён' : 'успешно обновлена'; } else if (isPlan == true) { text = 'опубликован'; } else { diff --git a/lib/features/create_plan/presentation/create_plan_screen.dart b/lib/features/create_plan/presentation/create_plan_screen.dart index b9e8979c..c3076ed0 100644 --- a/lib/features/create_plan/presentation/create_plan_screen.dart +++ b/lib/features/create_plan/presentation/create_plan_screen.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart'; @@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart'; class CreatePlanScreen extends StatelessWidget { static const name = 'createPlanScreen'; static const path = '/create-Plan-screen'; - const CreatePlanScreen({super.key}); + const CreatePlanScreen({super.key, this.plan}); + + final PlanModel? plan; @override Widget build(BuildContext context) { - return BlocProvider(create: (context) => sl(), child: const CreatePlanScreenContent()); + return BlocProvider( + create: (context) { + final cubit = sl(); + final existingPlan = plan; + if (existingPlan != null) { + cubit.loadExisting(existingPlan); + } + return cubit; + }, + child: const CreatePlanScreenContent(), + ); } } @@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State { @override Widget build(BuildContext context) { final s = S.of(context); + final isEditing = context.read().sourcePlan != null; return BlocBuilder( buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading, builder: (context, state) { if (state.planCreated) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; + final isEditing = context.read().sourcePlan != null; context.pop(true); showBottomSheetWithBlur( userRootNavigator: true, context: context, - child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true), + child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing), ); }); } @@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State { appBar: AppBar( centerTitle: true, title: Text( - s.adding_plan, + isEditing ? 'Редактирование плана' : s.adding_plan, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, softWrap: true, @@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State { return Column( children: [ CustomTextFormField( + key: const ValueKey('plan-name'), hintText: s.plan_label_desc, + initialValue: state.planData.name, hasError: state.validationState.titleHasError, onChanged: (value) { context.read().setName(value); @@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State { children: [ MarkdownEditor( key: _mdKey, + initialText: state.planData.details, hintText: s.plan_desc_example, hasError: state.validationState.descriptionHasError, minLines: 6, @@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State { onPressed: () async { await context.read().publishPlan(); }, - text: s.publish, + text: isEditing ? 'Обновить' : s.publish, height: 52, buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonStyle: ButtonStyle( 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 be09737e..35687055 100644 --- a/lib/features/create_plan/presentation/cubit/create_plan_cubit.dart +++ b/lib/features/create_plan/presentation/cubit/create_plan_cubit.dart @@ -4,16 +4,57 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; class CreatePlanCubit extends Cubit { final CreatePlanRepository createPlanRepository; + PlanModel? sourcePlan; + CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial()); + void loadExisting(PlanModel plan) { + sourcePlan = plan; + final durationIndex = durationValues.indexOf(plan.duration); + emit( + state.copyWith( + durationIndex: durationIndex >= 0 ? durationIndex : null, + selectedOptionIndex: plan.packetsCount == 3 ? 0 : null, + planData: state.planData.copyWith( + name: plan.name, + details: plan.details, + duration: plan.duration, + state: plan.state, + type: [ + plan.type.slim ?? false, + plan.type.fit ?? false, + plan.type.wellness ?? false, + plan.type.behavior ?? false, + ], + options: [ + _packetOrEmpty(plan.packet1Options), + plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(), + plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(), + ], + ), + ), + ); + } + + PacketOptions _packetOrEmpty(PacketOptions? option) { + if (option == null) { + return PacketOptions.empty(); + } + final price = option.price; + final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200; + return option.copyWith(price: normalizedPrice); + } + void setDetails(String text) { emit(state.copyWith(planData: state.planData.copyWith(details: text))); validateDetails(); @@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit { } bool validateImage() { - if (state.planData.image == null) { + if (state.planData.image == null && sourcePlan == null) { emit(state.copyWith.validationState(imageHasError: true)); return false; } @@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit { 'type[fit]': state.planData.type[1] ? '1' : '0', 'type[wellness]': state.planData.type[2] ? '1' : '0', 'type[behavior]': state.planData.type[3] ? '1' : '0', - 'image': state.planData.image != null - ? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path, - filename: state.planData.image!.name) - : null }); + if (state.planData.image != null) { + final image = state.planData.image!; + planMap['image'] = await MultipartFile.fromFile( + (await ImageCompressUtility.compressFile(File(image.path))).path, + filename: image.name, + ); + } if (state.planData.options[1].short.isNotEmpty) { planMap.addAll({ 'packets_count': 3, 'packet1_options': { 'short': state.planData.options[0].short, - 'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)), + 'price': _optionPrice(state.planData.options[0].price), 'description_md': state.planData.options[0].description, 'items': Map.fromIterable(state.planData.options[0].items), 'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0', }, 'packet2_options': { 'short': state.planData.options[1].short, - 'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)), + 'price': _optionPrice(state.planData.options[1].price), 'description_md': state.planData.options[1].description, 'items': Map.fromIterable(state.planData.options[1].items), 'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0', }, 'packet3_options': { 'short': state.planData.options[2].short, - 'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)), + 'price': _optionPrice(state.planData.options[2].price), 'description_md': state.planData.options[2].description, 'items': Map.fromIterable(state.planData.options[2].items), 'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0', @@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit { 'packets_count': 1, 'packet1_options': { 'short': state.planData.options[0].short, - 'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)), + 'price': _optionPrice(state.planData.options[0].price), 'description_md': state.planData.options[0].description, 'items': Map.fromIterable(state.planData.options[0].items), 'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0', }, }); } - await createPlanRepository.createPlan(model: planMap); + final editingPlan = sourcePlan; + if (editingPlan == null) { + await createPlanRepository.createPlan(model: planMap); + } else { + await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap); + } emit(state.copyWith(isLoading: false, planCreated: true)); } } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(isLoading: false, planCreated: true)); + emit(state.copyWith(isLoading: false, planCreated: false)); + final isForbidden = e is DioException && e.response?.statusCode == 403; + showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план'); } } + + int _optionPrice(dynamic price) { + if (price is num) { + return price.round(); + } + return int.parse(price.toString()); + } } diff --git a/lib/features/create_plan/presentation/widgets/plan_image_picker_widget.dart b/lib/features/create_plan/presentation/widgets/plan_image_picker_widget.dart index 67af4a61..3083c8b6 100644 --- a/lib/features/create_plan/presentation/widgets/plan_image_picker_widget.dart +++ b/lib/features/create_plan/presentation/widgets/plan_image_picker_widget.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:dotted_border/dotted_border.dart'; +import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State { final s = S.of(context); return BlocBuilder( builder: (context, state) { + final selectedImage = state.planData.image; + final existingImage = context.read().sourcePlan?.image; + final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty); + final ImageProvider? coverImage; + if (selectedImage != null) { + coverImage = FileImage(File(selectedImage.path)); + } else if (existingImage != null && existingImage.isNotEmpty) { + final url = existingImage.startsWith('http') ? existingImage : '${sl().baseUrl}/storage/$existingImage'; + coverImage = NetworkImage(url); + } else { + coverImage = null; + } return Column( children: [ GestureDetector( @@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State { height: 240, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null, + image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null, color: appTheme.gray50, ), width: MediaQuery.sizeOf(context).width, - padding: state.planData.image != null ? null : const EdgeInsets.all(8), - child: state.planData.image != null + padding: hasCover ? null : const EdgeInsets.all(8), + child: selectedImage != null ? Padding( padding: const EdgeInsets.only(top: 6, right: 6), child: GestureDetector( @@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State { ), ), ) + : hasCover + ? const SizedBox.shrink() : Stack( children: [ DottedBorder( diff --git a/lib/features/create_plan/repositories/create_plan_repository.dart b/lib/features/create_plan/repositories/create_plan_repository.dart index 17ed3298..4ae3ed45 100644 --- a/lib/features/create_plan/repositories/create_plan_repository.dart +++ b/lib/features/create_plan/repositories/create_plan_repository.dart @@ -11,7 +11,13 @@ class CreatePlanRepository { }) async { final formData = FormData.fromMap(model); return await createPlanService.createPlan(formData: formData); - } - + + Future updatePublishedPlan({ + required String slug, + required Map model, + }) async { + final formData = FormData.fromMap(model); + return await createPlanService.updatePublishedPlan(slug: slug, formData: formData); + } } diff --git a/lib/features/create_plan/services/create_plan_service.dart b/lib/features/create_plan/services/create_plan_service.dart index 7236a956..cafcba2f 100644 --- a/lib/features/create_plan/services/create_plan_service.dart +++ b/lib/features/create_plan/services/create_plan_service.dart @@ -12,5 +12,11 @@ abstract class CreatePlanService { Future createPlan({ @Body() required FormData formData, }); - + + @POST('/api/plans/update/published/{slug}') + @MultiPart() + Future updatePublishedPlan({ + @Path() required String slug, + @Body() required FormData formData, + }); } diff --git a/lib/features/diary/presentation/diary_edit_access.dart b/lib/features/diary/presentation/diary_edit_access.dart index 235fafb9..7a711dde 100644 --- a/lib/features/diary/presentation/diary_edit_access.dart +++ b/lib/features/diary/presentation/diary_edit_access.dart @@ -1,6 +1,6 @@ /// Finished and last-day-finished diaries stay editable on the calendar day /// itself and on the next calendar day. Older finished days stay read-only. -const int diaryFinishedEditWindowInDays = 1; +const int diaryFinishedEditWindowInDays = 7; bool canEditDiaryDay({ required bool isConsultantView, diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index 5725a418..f979e73a 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -89,6 +89,7 @@ class _PlanWidgetState extends State { path: 'plan/${widget.plan.slug}', id: widget.plan.id, slug: widget.plan.slug, + plan: widget.plan, complainedContentType: ComplainedContent.plan, userSlug: widget.plan.author.authorSlug, ), diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index d10561ef..77a98c2c 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -196,6 +196,9 @@ class _AboutPlanScreenState extends State { isAuthor: state.planModel?.author.authorIsMe ?? false, isAuthorAdmin: state.planModel?.author.isAdmin ?? false, path: 'plan/${widget.slug}', + slug: widget.slug, + id: state.planModel?.id, + plan: state.planModel, complainedContentType: ComplainedContent.plan, userSlug: state.planModel?.author.authorSlug ?? '', ), @@ -376,6 +379,9 @@ class _AboutPlanScreenState extends State { isAuthor: state.planModel?.author.authorIsMe ?? false, isAuthorAdmin: state.planModel?.author.isAdmin ?? false, path: 'plan/${widget.slug}', + slug: widget.slug, + id: state.planModel?.id, + plan: state.planModel, complainedContentType: ComplainedContent.plan, userSlug: state.planModel?.author.authorSlug ?? '', ), diff --git a/pubspec.yaml b/pubspec.yaml index 498d3791..e5a30e54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.48+137 +version: 1.3.49+138 publish_to: 'none' environment: diff --git a/test/core/widgets/custom_popup_test.dart b/test/core/widgets/custom_popup_test.dart index 0f7b3553..a8434ece 100644 --- a/test/core/widgets/custom_popup_test.dart +++ b/test/core/widgets/custom_popup_test.dart @@ -4,6 +4,9 @@ import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter/material.dart'; @@ -185,6 +188,68 @@ void main() { expect(find.text('Удалить'), findsOneWidget); }); + testWidgets('CustomPopup for author recipe shows edit when recipe is provided', (tester) async { + await tester.pumpWidget( + wrap( + CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.recipe, + path: 'recipes/slug', + slug: 'slug', + recipe: _recipe(), + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Редактировать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('CustomPopup for author diet shows edit when diet is provided', (tester) async { + await tester.pumpWidget( + wrap( + CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.diet, + path: 'diets/slug', + id: 1, + diet: _diet(), + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Редактировать'), findsOneWidget); + }); + + testWidgets('CustomPopup for author plan shows edit when plan is provided', (tester) async { + await tester.pumpWidget( + wrap( + CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.plan, + path: 'plans/slug', + slug: 'slug', + plan: _plan(), + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Редактировать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + }); + testWidgets('CustomPopup for non-author without complained type still renders static actions', (tester) async { await tester.pumpWidget( wrap( @@ -206,5 +271,72 @@ void main() { expect(find.text('Пожаловаться'), findsOneWidget); expect(find.text('Заблокировать автора'), findsOneWidget); }); - +} + +Recipe _recipe() { + return const Recipe( + id: 1, + image: 'image.jpg', + state: 'active', + cookingTime: 30, + activeCookingTime: 15, + ingredients: null, + description: 'Описание', + steps: null, + videoUrl: null, + title: 'Рецепт', + type: 'main', + slug: 'recipe-slug', + commentsCount: 0, + wasLikedByMe: false, + isFavorite: false, + calories: 100, + likesCount: 0, + viewsCount: 0, + tags: [], + author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true), + ); +} + +Diet _diet() { + return const Diet( + id: 1, + slug: 'diet-slug', + name: 'Диета', + image: 'image.jpg', + duration: 7, + state: DietState.active, + type: DietType(), + details: 'Описание', + dailySchedule: {}, + author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + wasLikedByMe: false, + isFavorite: false, + dailyNutrition: [], + ); +} + +PlanModel _plan() { + return const PlanModel( + id: 1, + slug: 'plan-slug', + name: 'План', + image: 'image.jpg', + duration: 7, + state: 'active', + type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + details: 'Описание', + packetsCount: 1, + author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + isFavorite: false, + minPriceOfOptions: 1200, + maxPriceOfOptions: 1200, + wasLikedByMe: false, + ); } diff --git a/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart b/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart index d0b1a78c..2931b39c 100644 --- a/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart +++ b/test/features/create_plan/presentation/cubit/create_plan_cubit_test.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -703,6 +705,51 @@ void main() { expect(cubit.state.planCreated, false); }); + test('loadExisting заполняет форму и обновляет существующий план', () async { + const plan = PlanModel( + id: 7, + slug: 'test-plan', + name: 'Test Plan', + image: 'cover.jpg', + duration: 14, + state: 'active', + type: PlanType(fit: true, slim: true, behavior: false, wellness: false), + details: 'Описание плана', + packetsCount: 1, + packet1Options: PacketOptions( + items: ['Консультации'], + price: 1500, + short: 'Базовый', + description: 'Описание опции', + hasPersonalDiet: false, + ), + author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + isFavorite: false, + minPriceOfOptions: 1500, + maxPriceOfOptions: 1500, + wasLikedByMe: false, + ); + + when(repository.updatePublishedPlan(slug: anyNamed('slug'), model: anyNamed('model'))).thenAnswer((_) async {}); + + cubit.loadExisting(plan); + expect(cubit.sourcePlan?.slug, 'test-plan'); + expect(cubit.state.planData.name, 'Test Plan'); + expect(cubit.state.planData.duration, 14); + expect(cubit.state.durationIndex, 1); + expect(cubit.state.planData.type, [true, true, false, false]); + expect(cubit.validateImage(), true); + + await cubit.publishPlan(); + + verify(repository.updatePublishedPlan(slug: 'test-plan', model: anyNamed('model'))).called(1); + verifyNever(repository.createPlan(model: anyNamed('model'))); + expect(cubit.state.planCreated, true); + }); + test('setPlanType - валидирует тип когда хотя бы один выбран', () { cubit.setPlanType([true, false, false, false]); expect(cubit.state.validationState.typeHasError, false); diff --git a/test/fixtures/doubles/create_plan_doubles.dart b/test/fixtures/doubles/create_plan_doubles.dart index 75fd6668..e330ae45 100644 --- a/test/fixtures/doubles/create_plan_doubles.dart +++ b/test/fixtures/doubles/create_plan_doubles.dart @@ -47,5 +47,10 @@ class _NoopCreatePlanService implements CreatePlanService { Future createPlan({required FormData formData}) { throw UnimplementedError('Not used in fixture tests'); } + + @override + Future updatePublishedPlan({required String slug, required FormData formData}) { + throw UnimplementedError('Not used in fixture tests'); + } }