feat: introduce My Diets and Create Diet features with associated UI, state management, and data handling.

This commit is contained in:
mso7nueh
2026-01-28 17:32:29 +03:00
parent 62fe5ebfa0
commit 2d7407ce99
10 changed files with 118 additions and 201 deletions

View File

@@ -180,40 +180,6 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
children: [ children: [
CustomOutlinedButton(
onPressed: () async {
context.read<CreateDietCubit>().setDietState(DietState.draft);
if (widget.diet != null) {
final result = await context.read<CreateDietCubit>().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<CreateDietCubit>().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<CreateDietCubit>().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( CustomOutlinedButton(
onPressed: state.isDietPublishing onPressed: state.isDietPublishing
? () {} ? () {}

View File

@@ -341,7 +341,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
bool validateSelectedOptionDescription() { bool validateSelectedOptionDescription() {
final option = state.planData.options[state.selectedOptionIndex ?? 0]; final option = state.planData.options[state.selectedOptionIndex ?? 0];
final optionsValidationsStates = [...state.validationState.optionsValidationState]; 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); optionsValidationsStates[state.selectedOptionIndex ?? 0] = optionsValidationsStates[state.selectedOptionIndex ?? 0].copyWith(descriptionHasError: true);
emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates));
return false; return false;
@@ -356,7 +356,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
final optionsValidationsStates = [...state.validationState.optionsValidationState]; final optionsValidationsStates = [...state.validationState.optionsValidationState];
bool result = true; 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); optionsValidationsStates[0] = optionsValidationsStates[0].copyWith(descriptionHasError: true);
emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates));
result = false; result = false;
@@ -365,7 +365,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates));
} }
if (state.selectedOptionIndex == null) return result; 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); optionsValidationsStates[1] = optionsValidationsStates[1].copyWith(descriptionHasError: true);
emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates));
result = false; result = false;
@@ -374,7 +374,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); 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); optionsValidationsStates[2] = optionsValidationsStates[2].copyWith(descriptionHasError: true);
emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates)); emit(state.copyWith.validationState(optionsValidationState: optionsValidationsStates));
result = false; result = false;

View File

@@ -94,7 +94,7 @@ class _OptionWidgetState extends State<OptionWidget> {
final price = double.tryParse(customerPriceController.text.replaceAll('₽', '')); final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽'; creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short; firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description; firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
return MainBackgroundBouble( return MainBackgroundBouble(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(

View File

@@ -66,7 +66,7 @@ abstract class PacketOptions with _$PacketOptions {
@JsonKey(name: "items", fromJson: _parseItems) required List<String> items, @JsonKey(name: "items", fromJson: _parseItems) required List<String> items,
@JsonKey(name: "price") required dynamic price, @JsonKey(name: "price") required dynamic price,
@JsonKey(name: "short") required String short, @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, @JsonKey(name: "has_personal_diet", fromJson: _parseHasPersonalDietField) required bool hasPersonalDiet,
}) = _PacketOptions; }) = _PacketOptions;

View File

@@ -60,8 +60,7 @@ class MyDietsScreenContent extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
return NestedScrollView( return NestedScrollView(
controller: switch (state.selectedStateIndex) { controller: switch (state.selectedStateIndex) {
1 => draftDietsScrollController, 1 => personalDietsScrollController,
2 => personalDietsScrollController,
_ => activeDietsScrollController _ => activeDietsScrollController
}, },
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
@@ -103,8 +102,7 @@ class MyDietsScreenContent extends StatelessWidget {
backgroundColor: appTheme.whiteA700, backgroundColor: appTheme.whiteA700,
borderRadius: 100, borderRadius: 100,
searchController: switch (state.selectedStateIndex) { searchController: switch (state.selectedStateIndex) {
1 => draftDietsSearchController, 1 => personalDietsSearchController,
2 => personalDietsSearchController,
_ => activeDietsSearchController _ => activeDietsSearchController
}, },
onChanged: (query) { onChanged: (query) {
@@ -146,8 +144,7 @@ class MyDietsScreenContent extends StatelessWidget {
return DataErrorWidget(onReload: () => context.read<MyDietsCubit>().loadAllDiets()); return DataErrorWidget(onReload: () => context.read<MyDietsCubit>().loadAllDiets());
} else { } else {
final gotDietLoadingError = state.activeDietsError != null && state.selectedStateIndex == 0 || final gotDietLoadingError = state.activeDietsError != null && state.selectedStateIndex == 0 ||
state.draftDietsError != null && state.selectedStateIndex == 1 || state.personalDietsError != null && state.selectedStateIndex == 1;
state.personalDietsError != null && state.selectedStateIndex == 2;
if (gotDietLoadingError) { if (gotDietLoadingError) {
return DataErrorWidget(onReload: () => context.read<MyDietsCubit>().loadCurrentDiets()); return DataErrorWidget(onReload: () => context.read<MyDietsCubit>().loadCurrentDiets());
@@ -158,8 +155,7 @@ class MyDietsScreenContent extends StatelessWidget {
} }
final diets = switch (state.selectedStateIndex) { final diets = switch (state.selectedStateIndex) {
1 => state.draftDiets, 1 => state.personalDiets,
2 => state.personalDiets,
_ => state.activeDiets, _ => state.activeDiets,
}; };

View File

@@ -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/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/enums/product_type.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/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.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/info_row.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.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/create_diet/presentation/create_diet_screen.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.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_cubit.dart';
import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_state.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_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/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -103,43 +100,56 @@ class _MyDietWidgetState extends State<MyDietWidget> {
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
children: [ children: [
// Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных)
Row( Row(
children: [ children: [
// Кнопка удаления (слева)
CustomOutlinedButton(
onPressed: () async {
final result = await context.read<MyDietsCubit>().deleteDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: DietDeletedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().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( Expanded(
child: CustomOutlinedButton( child: CustomOutlinedButton(
onPressed: () async { onPressed: () async {
if (state.selectedStateIndex == 1) { if (state.selectedStateIndex == 1) {
final result = await context.read<MyDietsCubit>().publishDiet(widget.diet.id); await showErrorDialog(context, title: 'Предупреждение', description: 'Вы можете создать новую диету на основе существующей.');
if (result) { AppRouter.navigatorKey.currentContext?.pushNamed(CreateDietScreen.name, extra: widget.diet);
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: SuccessPublishedDietOrPlanBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().loadAllDiets();
} else {
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
}
} else if (state.selectedStateIndex == 2) {
GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id);
} else { } else {
GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet); GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet);
} }
}, },
text: switch (state.selectedStateIndex) { text: s.edit,
1 => s.publish,
2 => s.toOffer,
_ => s.edit,
},
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0),
side: const WidgetStatePropertyAll(BorderSide.none), side: const WidgetStatePropertyAll(BorderSide.none),
), ),
), ),
), ),
// Кнопка поделиться (справа) для опубликованных
if (state.selectedStateIndex == 0) ...[ if (state.selectedStateIndex == 0) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
CustomOutlinedButton( CustomOutlinedButton(
@@ -171,69 +181,23 @@ class _MyDietWidgetState extends State<MyDietWidget> {
], ],
], ],
), ),
const SizedBox(height: 8), // Нижний ряд: Предложить (только для персональных)
Row( if (state.selectedStateIndex == 1) ...[
children: [ const SizedBox(height: 8),
Expanded( CustomOutlinedButton(
child: CustomOutlinedButton( onPressed: () {
onPressed: () async { GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id);
if (state.selectedStateIndex == 0) { },
final result = await context.read<MyDietsCubit>().unpublishDiet(widget.diet.id); text: s.toOffer,
if (result) { height: 52,
if (!context.mounted) return; buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
showBottomSheetWithBlur( buttonStyle: ButtonStyle(
context: context, shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
height: 250, backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
child: DietDraftedBottomSheet(title: widget.diet.name), side: const WidgetStatePropertyAll(BorderSide.none),
);
await context.read<MyDietsCubit>().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),
),
),
), ),
const SizedBox(width: 6), ),
CustomOutlinedButton( ],
onPressed: () async {
final result = await context.read<MyDietsCubit>().deleteDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: DietDeletedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().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), const SizedBox(height: 8),
], ],
), ),

View File

@@ -21,7 +21,6 @@ class _MyDietsTabState extends State<MyDietsTab> {
final s = S.of(context); final s = S.of(context);
if (tabTitles.isEmpty) { if (tabTitles.isEmpty) {
tabTitles.add(s.published); tabTitles.add(s.published);
tabTitles.add(s.drafted);
tabTitles.add(s.personal); tabTitles.add(s.personal);
} }
super.didChangeDependencies(); super.didChangeDependencies();
@@ -31,37 +30,29 @@ class _MyDietsTabState extends State<MyDietsTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<MyDietsCubit, MyDietsState>( return BlocBuilder<MyDietsCubit, MyDietsState>(
builder: (context, state) { builder: (context, state) {
return Align( return Padding(
alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 8),
child: SingleChildScrollView( child: Container(
scrollDirection: Axis.horizontal, height: 48,
decoration: BoxDecoration(
color: appTheme.whiteA700,
borderRadius: BorderRadius.circular(100),
),
padding: const EdgeInsets.all(4),
child: Row( child: Row(
children: [ children: [
Container( ...List.generate(
width: 484, 2,
height: 48, (index) => Expanded(
decoration: BoxDecoration( child: AnimatedOptionButton(
color: appTheme.whiteA700, textStyle: TextStyles.textSMedium,
borderRadius: BorderRadius.circular(100), isSelected: state.selectedStateIndex == index,
), text: tabTitles[index],
padding: const EdgeInsets.all(4), onTap: () {
child: Row( widget.additionalOnTap?.call();
children: [ context.read<MyDietsCubit>().changeSelectedStateIndex(index);
...List.generate( },
3, ),
(index) => Expanded(
child: AnimatedOptionButton(
textStyle: TextStyles.textSMedium,
isSelected: state.selectedStateIndex == index,
text: tabTitles[index],
onTap: () {
widget.additionalOnTap?.call();
context.read<MyDietsCubit>().changeSelectedStateIndex(index);
},
),
),
),
],
), ),
), ),
], ],

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart'; import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
@@ -14,24 +16,11 @@ class MyPlansRepository {
} }
} }
Future<PlanPaginationModel> getPlans({ Future<PlanPaginationModel> getPlans({int page = 1, String? query, String? types, int? duration, int? minPrice, int? maxPrice}) async {
int page = 1,
String? query,
String? types,
int? duration,
int? minPrice,
int? maxPrice,
}) async {
try { try {
return (await myPlansService.getPlans( return (await myPlansService.getPlans(page: page, query: query, types: types, duration: duration, minPrice: minPrice, maxPrice: maxPrice));
page: page, } catch (e, st) {
query: query, log(e.toString(), error: e, stackTrace: st);
types: types,
duration: duration,
minPrice: minPrice,
maxPrice: maxPrice,
));
} catch (e) {
rethrow; rethrow;
} }
} }

View File

@@ -41,27 +41,38 @@ class PersonalDietSuggestionScreenContent extends StatelessWidget {
), ),
body: state.isLoading body: state.isLoading
? const Center(child: Loader()) ? const Center(child: Loader())
: SingleChildScrollView( : (state.consultationsWithDiet.isEmpty && state.consultationsWithoutDiet.isEmpty)
child: Column( ? Center(
children: [ child: Padding(
...List.generate(state.consultationsWithDiet.length, (index) { padding: const EdgeInsets.all(32.0),
return ConsultationWidget( child: Text(
consultationModel: state.consultationsWithDiet[index], 'Нет активных консультаций с персональной диетой',
dietName: state.dietName, style: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
); textAlign: TextAlign.center,
}), ),
const SizedBox(
height: 12,
), ),
...List.generate(state.consultationsWithoutDiet.length, (index) { )
return ConsultationWidget( : SingleChildScrollView(
consultationModel: state.consultationsWithoutDiet[index], child: Column(
dietName: state.dietName, 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,
);
})
],
),
),
); );
}, },
); );

View File

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.13+94 version: 1.3.14+95
publish_to: 'none' publish_to: 'none'
environment: environment: