feat(UGPC): issue-777

This commit is contained in:
misha
2026-10-12 08:50:33 +03:00
parent f8e06b6d7a
commit cb312037b2
15 changed files with 442 additions and 36 deletions

View File

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

View File

@@ -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<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
Future<void> _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<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
} catch (_) {}
try {
context.read<RecipesCubit>().loadRecipesList();
} catch (_) {}
case ComplainedContent.diet:
final currentDiet = diet;
if (currentDiet == null) {
return;
}
try {
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
} catch (_) {}
try {
context.read<DietsCubit>().loadDiets();
} catch (_) {}
case ComplainedContent.plan:
final currentPlan = plan;
if (currentPlan == null) {
return;
}
try {
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
} catch (_) {}
try {
context.read<PlansCubit>().loadPlans();
} catch (_) {}
try {
context.read<MyPlansCubit>().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<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
final entries = <PopupMenuEntry>[];
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 {

View File

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

View File

@@ -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<CreatePlanCubit>(), child: const CreatePlanScreenContent());
return BlocProvider(
create: (context) {
final cubit = sl<CreatePlanCubit>();
final existingPlan = plan;
if (existingPlan != null) {
cubit.loadExisting(existingPlan);
}
return cubit;
},
child: const CreatePlanScreenContent(),
);
}
}
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
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<CreatePlanCubit>().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<CreatePlanScreenContent> {
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<CreatePlanScreenContent> {
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<CreatePlanCubit>().setName(value);
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
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<CreatePlanScreenContent> {
onPressed: () async {
await context.read<CreatePlanCubit>().publishPlan();
},
text: s.publish,
text: isEditing ? 'Обновить' : s.publish,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(

View File

@@ -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<CreatePlanState> {
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<CreatePlanState> {
}
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<CreatePlanState> {
'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<CreatePlanState> {
'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());
}
}

View File

@@ -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<PlanImagePickerWidget> {
final s = S.of(context);
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
builder: (context, state) {
final selectedImage = state.planData.image;
final existingImage = context.read<CreatePlanCubit>().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<AppConfig>().baseUrl}/storage/$existingImage';
coverImage = NetworkImage(url);
} else {
coverImage = null;
}
return Column(
children: [
GestureDetector(
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
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<PlanImagePickerWidget> {
),
),
)
: hasCover
? const SizedBox.shrink()
: Stack(
children: [
DottedBorder(

View File

@@ -11,7 +11,13 @@ class CreatePlanRepository {
}) async {
final formData = FormData.fromMap(model);
return await createPlanService.createPlan(formData: formData);
}
Future<void> updatePublishedPlan({
required String slug,
required Map<String, dynamic> model,
}) async {
final formData = FormData.fromMap(model);
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
}
}

View File

@@ -12,5 +12,11 @@ abstract class CreatePlanService {
Future<void> createPlan({
@Body() required FormData formData,
});
@POST('/api/plans/update/published/{slug}')
@MultiPart()
Future<void> updatePublishedPlan({
@Path() required String slug,
@Body() required FormData formData,
});
}

View File

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

View File

@@ -89,6 +89,7 @@ class _PlanWidgetState extends State<PlanWidget> {
path: 'plan/${widget.plan.slug}',
id: widget.plan.id,
slug: widget.plan.slug,
plan: widget.plan,
complainedContentType: ComplainedContent.plan,
userSlug: widget.plan.author.authorSlug,
),

View File

@@ -196,6 +196,9 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
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<AboutPlanScreen> {
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 ?? '',
),

View File

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

View File

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

View File

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

View File

@@ -47,5 +47,10 @@ class _NoopCreatePlanService implements CreatePlanService {
Future<void> createPlan({required FormData formData}) {
throw UnimplementedError('Not used in fixture tests');
}
@override
Future<void> updatePublishedPlan({required String slug, required FormData formData}) {
throw UnimplementedError('Not used in fixture tests');
}
}