feat(recipe): my recipes screen

This commit is contained in:
mso7nueh
2025-05-27 17:38:46 +03:00
parent 242cb474cc
commit 566a96b18e
10 changed files with 372 additions and 168 deletions

View File

@@ -81,8 +81,11 @@ import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recip
import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart';
import 'package:eatvio_app/features/create_recipe/services/recipe_service.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:eatvio_app/features/recipe/services/about_recipe_service.dart';
import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart';
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
@@ -140,6 +143,7 @@ Future<void> init() async {
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl()));
// Repository
sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl()));
@@ -167,6 +171,7 @@ Future<void> init() async {
sl.registerLazySingleton<CancelPlanRepository>(() => CancelPlanRepository(cancelService: sl()));
sl.registerLazySingleton<CreatePlanRepository>(() => CreatePlanRepository(createPlanService: sl()));
sl.registerLazySingleton<NotificationsRepository>(() => NotificationsRepository(notificationsService: sl()));
sl.registerFactory<MyRecipesRepository>(() => MyRecipesRepository(myRecipesService: sl()));
// Services
sl.registerLazySingleton<ChatService>(() => ChatService(sl()));
@@ -195,6 +200,7 @@ Future<void> init() async {
sl.registerLazySingleton<CancelPlanService>(() => CancelPlanService(sl()));
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
sl.registerLazySingleton<MyRecipesService>(() => MyRecipesService(sl()));
// External
sl.registerLazySingleton(

View File

@@ -16,8 +16,8 @@ abstract class Recipe with _$Recipe {
@JsonKey(name: 'description_md') required String description,
required List<Step>? steps,
@JsonKey(name: 'video_url') required String? videoUrl,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
@JsonKey(name: 'created_at') DateTime? createdAt,
@JsonKey(name: 'updated_at') DateTime? updatedAt,
required String? title,
required String? type,
required String slug,

View File

@@ -26,6 +26,7 @@ import 'package:share_plus/share_plus.dart';
class MyDietWidget extends StatefulWidget {
final Diet diet;
const MyDietWidget({
super.key,
required this.diet,

View File

@@ -0,0 +1,36 @@
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_recipe_model.g.dart';
part 'my_recipe_model.freezed.dart';
@freezed
abstract class MyRecipePaginationModel with _$MyRecipePaginationModel {
const factory MyRecipePaginationModel({
required List<MyRecipeModel> data,
}) = _MyRecipePaginationModel;
factory MyRecipePaginationModel.fromJson(Map<String, Object?> json) => _$MyRecipePaginationModelFromJson(json);
}
@freezed
abstract class MyRecipeModel with _$MyRecipeModel {
const factory MyRecipeModel({
required int id,
required String image,
required String state,
@JsonKey(name: 'cooking_time') required int cookingTime,
@JsonKey(name: 'active_time') required int activeCookingTime,
@JsonKey(name: 'description_md') required String description,
required List<Step>? steps,
@JsonKey(name: 'video_url') required String? videoUrl,
required String? title,
required String slug,
@JsonKey(name: 'energy_per_serving') required double calories,
@JsonKey(name: 'total_likes') required int likesCount,
@JsonKey(name: 'total_views') required int viewsCount,
}) = _MyRecipeModel;
factory MyRecipeModel.fromJson(Map<String, Object?> json) => _$MyRecipeModelFromJson(json);
}

View File

@@ -0,0 +1,21 @@
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MyRecipesCubit extends Cubit<MyRecipesState> {
final MyRecipesRepository myRecipesRepository;
MyRecipesCubit({
required this.myRecipesRepository,
}) : super(const MyRecipesState.loading());
getMyRecipes() async {
emit(const MyRecipesState.loading());
try {
final myRecipes = await myRecipesRepository.getMyRecipes();
emit(MyRecipesState.loaded(recipes: myRecipes));
} catch (e) {
emit(const MyRecipesState.error());
}
}
}

View File

@@ -0,0 +1,17 @@
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_recipes_state.freezed.dart';
@freezed
abstract class MyRecipesState with _$MyRecipesState {
const factory MyRecipesState() = _MyRecipesState;
const factory MyRecipesState.loading() = MyRecipesStateLoading;
const factory MyRecipesState.loaded({
required List<MyRecipeModel> recipes,
}) = MyRecipesStateLoaded;
const factory MyRecipesState.error() = MyRecipesStateError;
}

View File

@@ -1,42 +1,18 @@
import 'package:easy_debounce/easy_debounce.dart';
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/widgets/add_item_button.dart';
import 'package:eatvio_app/core/widgets/custom_search_bar.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/diet_filter_bottom_sheet.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/filter_badge.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/my_diet_widget.dart';
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipes_tab.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart';
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class MyRecipesScreen extends StatelessWidget {
const MyRecipesScreen({super.key});
static const name = 'myRecipesScreen';
static const path = '/my-recipes-screen';
@override
Widget build(BuildContext context) {
return BlocProvider(create: (context) => sl<MyDietsCubit>()..loadAllDiets(), child: MyRecipesScreenContent());
}
}
class MyRecipesScreenContent extends StatelessWidget {
MyRecipesScreenContent({super.key});
final activeDietsScrollController = ScrollController();
final draftDietsScrollController = ScrollController();
final activeDietsSearchController = SearchController();
final draftDietsSearchController = SearchController();
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -52,144 +28,20 @@ class MyRecipesScreenContent extends StatelessWidget {
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
body: BlocBuilder<MyDietsCubit, MyDietsState>(
buildWhen: (previous, current) => previous.selectedStateIndex != current.selectedStateIndex,
builder: (context, state) {
return NestedScrollView(
controller: switch (state.selectedStateIndex) {
1 => draftDietsScrollController,
_ => activeDietsScrollController
},
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverAppBar(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)),
),
backgroundColor: appTheme.gray100,
foregroundColor: appTheme.gray100,
automaticallyImplyLeading: false,
stretchTriggerOffset: 1,
floating: true,
primary: false,
snap: true,
scrolledUnderElevation: 0,
bottom: PreferredSize(
preferredSize: Size(
MediaQuery.sizeOf(context).width,
64,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: MyRecipesTab(),
),
const SizedBox(height: 8),
Row(
children: [
const SizedBox(width: 4),
Expanded(
child: CustomSearchBar(
enableIcon: false,
backgroundColor: appTheme.whiteA700,
borderRadius: 100,
searchController: switch (state.selectedStateIndex) {
1 => draftDietsSearchController,
_ => activeDietsSearchController
},
onChanged: (query) {
EasyDebounce.debounce(
'searchDiets',
const Duration(milliseconds: 500),
() {
context.read<MyDietsCubit>().loadCurrentDiets(query: query == '' ? null : query);
},
);
},
),
),
const SizedBox(width: 12),
AddItemButton(
onTap: () => GoRouter.of(context).pushNamed(CreateDietScreen.name),
),
const SizedBox(width: 4),
],
),
const SizedBox(height: 16),
],
),
),
),
),
];
},
body: RefreshIndicator(
onRefresh: () => context.read<MyDietsCubit>().loadCurrentDiets(),
child: BlocBuilder<MyDietsCubit, MyDietsState>(
builder: (BuildContext context, MyDietsState state) {
if (state.isLoading) {
return Scaffold(
backgroundColor: Colors.grey.shade200,
body: const Loader(),
);
} else if (state.error != null) {
return DataErrorWidget(onReload: () => context.read<MyDietsCubit>().loadAllDiets());
} else {
final gotDietLoadingError = state.activeDietsError != null && state.selectedStateIndex == 0 ||
state.draftDietsError != null && state.selectedStateIndex == 1;
if (gotDietLoadingError) {
return DataErrorWidget(onReload: () => context.read<MyDietsCubit>().loadCurrentDiets());
}
if (state.isLoading) {
return const Loader();
}
final diets = switch (state.selectedStateIndex) {
1 => state.draftDiets,
_ => state.activeDiets,
};
return Stack(
children: [
const SizedBox.expand(),
ListView.separated(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
return MyDietWidget(
diet: diets[index],
key: ValueKey(diets[index].id),
);
},
separatorBuilder: (context, index) {
return const SizedBox(height: 20);
},
itemCount: diets.length,
),
Positioned(
right: 0,
bottom: 74,
child: FilterBadge(
filterBottomSheet: DietFilterBottomSheet(
myDietsCubit: context.read<MyDietsCubit>(),
),
),
),
],
);
}
},
),
),
);
},
body: BlocProvider<MyRecipesCubit>(
create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(),
child: BlocBuilder<MyRecipesCubit, MyRecipesState>(
builder: (BuildContext context, MyRecipesState state) {
if (state is MyRecipesStateLoaded) {
return ListView.separated(
itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]),
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemCount: state.recipes.length,
);
}
return Container();
},
),
),
);
}

View File

@@ -0,0 +1,242 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/const.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/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:share_plus/share_plus.dart';
class MyRecipeWidget extends StatelessWidget {
final MyRecipeModel recipe;
const MyRecipeWidget({
super.key,
required this.recipe,
});
@override
Widget build(BuildContext context) {
// final s = S.of(context);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
InkWell(
// onTap: () => GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'diet': widget.diet, 'navigateToComments': false}),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: CachedNetworkImage(
imageUrl: '$baseUrl/storage/${recipe.image}',
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
),
),
),
// Positioned(
// bottom: 16,
// child: SizedBox(
// width: MediaQuery.sizeOf(context).width,
// child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.diet),
// ),
// ),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
recipe.title!,
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
),
),
if (recipe.description.isNotEmpty) ...[
const SizedBox(
height: 12,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Html(
data: recipe.description.length > 200 ? '${recipe.description.substring(0, 200)}...' : recipe.description,
style: {
"*": Style(
margin: Margins.zero,
color: appTheme.black,
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
fontWeight: TextStyles.textSMedium.fontWeight ?? FontWeight.w400,
),
},
),
),
],
],
),
const SizedBox(height: 20),
BlocBuilder<MyRecipesCubit, MyRecipesState>(
builder: (BuildContext context, MyRecipesState state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Row(
children: [
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) {
// } else {
// GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet);
// }
},
text: '',
// text: switch (state.selectedStateIndex) {
// 1 => s.publish,
// 2 => s.toOffer,
// _ => s.edit,
// },
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),
),
),
),
// if (state.selectedStateIndex == 0) ...[
const SizedBox(width: 6),
CustomOutlinedButton(
onPressed: () async {
final box = context.findRenderObject() as RenderBox?;
await SharePlus.instance.share(
ShareParams(
uri: Uri.parse('$baseUrl/ru/recipe/${recipe.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
height: 52,
width: 52,
leftIcon: SvgPicture.asset(
'assets/icons/share2.svg',
width: 20,
height: 20,
colorFilter: ColorFilter.mode(appTheme.whiteA700, BlendMode.srcIn),
),
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red),
buttonStyle: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none),
),
text: '',
),
],
// ],
),
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: '',
// 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),
],
),
);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
class MyRecipesRepository {
final MyRecipesService myRecipesService;
MyRecipesRepository({required this.myRecipesService});
Future<List<MyRecipeModel>> getMyRecipes() async {
try {
return (await myRecipesService.getMyRecipes()).data;
} catch (e) {
rethrow;
}
}
}

View File

@@ -0,0 +1,13 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
import 'package:retrofit/retrofit.dart';
part 'my_recipes_service.g.dart';
@RestApi()
abstract class MyRecipesService {
factory MyRecipesService(Dio dio, {String baseUrl}) = _MyRecipesService;
@GET('/api/recipes/my')
Future<MyRecipePaginationModel> getMyRecipes();
}