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),
child: Column(
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(
onPressed: state.isDietPublishing
? () {}

View File

@@ -341,7 +341,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
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<CreatePlanState> {
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<CreatePlanState> {
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<CreatePlanState> {
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;

View File

@@ -94,7 +94,7 @@ class _OptionWidgetState extends State<OptionWidget> {
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(

View File

@@ -66,7 +66,7 @@ abstract class PacketOptions with _$PacketOptions {
@JsonKey(name: "items", fromJson: _parseItems) required List<String> 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;

View File

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

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/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<MyDietWidget> {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
// Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных)
Row(
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(
child: CustomOutlinedButton(
onPressed: () async {
if (state.selectedStateIndex == 1) {
final result = await context.read<MyDietsCubit>().publishDiet(widget.diet.id);
if (result) {
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);
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<MyDietWidget> {
],
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: CustomOutlinedButton(
onPressed: () async {
if (state.selectedStateIndex == 0) {
final result = await context.read<MyDietsCubit>().unpublishDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
height: 250,
child: DietDraftedBottomSheet(title: widget.diet.name),
);
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),
),
),
// Нижний ряд: Предложить (только для персональных)
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<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),
],
),

View File

@@ -21,7 +21,6 @@ class _MyDietsTabState extends State<MyDietsTab> {
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<MyDietsTab> {
Widget build(BuildContext context) {
return BlocBuilder<MyDietsCubit, MyDietsState>(
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<MyDietsCubit>().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<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/my_plans/services/my_plans_service.dart';
@@ -14,24 +16,11 @@ class MyPlansRepository {
}
}
Future<PlanPaginationModel> getPlans({
int page = 1,
String? query,
String? types,
int? duration,
int? minPrice,
int? maxPrice,
}) async {
Future<PlanPaginationModel> 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;
}
}

View File

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

View File

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