From e0ec45e19e731d34c26c081d4dc8cfeb22048dff Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 18 Dec 2024 11:56:20 +0300 Subject: [PATCH] feat(recipe): add creating recipes --- assets/icons/add_step_photo.svg | 10 + assets/icons/upload_photo_of_portion.svg | 10 + lib/core/app_router.dart | 8 - lib/core/locator_service.dart | 4 +- lib/core/widgets/custom_drop_down.dart | 5 - lib/core/widgets/custom_text_form_field.dart | 6 + .../presentation/pages/diary_screen.dart | 3 +- .../widgets/calories_chart_widget.dart | 1 - .../feed/presentation/pages/feed_screen.dart | 3 + .../entities/recipe_create_entity.dart | 50 ++ .../recipe_create_entity.freezed.dart | 722 ++++++++++++++++++ .../create_recipe_cubit.dart | 219 ++++++ .../create_recipe_state.dart | 11 + .../create_recipe_state.freezed.dart | 160 ++++ .../pages/recipe_create_screen.dart | 537 ++----------- .../presentation/widgets/image_widget.dart | 121 +++ .../widgets/info_about_recipe_widget.dart | 113 +++ .../widgets/ingredients_widget.dart | 219 ++++++ .../presentation/widgets/portion_widget.dart | 69 ++ .../widgets/step_image_widget.dart | 126 +++ .../presentation/widgets/steps_widget.dart | 135 ++++ .../presentation/widgets/tags_widget.dart | 82 ++ .../widgets/time_of_cook_widget.dart | 151 ++++ pubspec.yaml | 12 +- 24 files changed, 2267 insertions(+), 510 deletions(-) create mode 100644 assets/icons/add_step_photo.svg create mode 100644 assets/icons/upload_photo_of_portion.svg create mode 100644 lib/features/recipes/entities/recipe_create_entity.freezed.dart create mode 100644 lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart create mode 100644 lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart create mode 100644 lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.freezed.dart create mode 100644 lib/features/recipes/presentation/widgets/image_widget.dart create mode 100644 lib/features/recipes/presentation/widgets/ingredients_widget.dart create mode 100644 lib/features/recipes/presentation/widgets/portion_widget.dart create mode 100644 lib/features/recipes/presentation/widgets/step_image_widget.dart create mode 100644 lib/features/recipes/presentation/widgets/steps_widget.dart create mode 100644 lib/features/recipes/presentation/widgets/time_of_cook_widget.dart diff --git a/assets/icons/add_step_photo.svg b/assets/icons/add_step_photo.svg new file mode 100644 index 00000000..2967b460 --- /dev/null +++ b/assets/icons/add_step_photo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/upload_photo_of_portion.svg b/assets/icons/upload_photo_of_portion.svg new file mode 100644 index 00000000..3149160f --- /dev/null +++ b/assets/icons/upload_photo_of_portion.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 899804f1..7a90c01c 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -29,7 +29,6 @@ import 'package:eatvio_app/features/profile/presentation/pages/profile_screen.da import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart'; import 'package:eatvio_app/features/recipes/presentation/pages/about_recipes_screen.dart'; import 'package:eatvio_app/features/recipes/presentation/pages/recipe_create_screen.dart'; -import 'package:eatvio_app/features/recipes/presentation/pages/recipe_edit_screen.dart'; import 'package:eatvio_app/features/registration/presentation/pages/registration_screen.dart'; import 'package:eatvio_app/features/settings/entities/settings_entity.dart'; import 'package:eatvio_app/features/settings/presentation/pages/change_email_screen.dart'; @@ -133,13 +132,6 @@ class AppRouter { name: 'recipeCreateScreen', builder: (context, state) => RecipeCreateScreen(), ), - GoRoute( - path: '/recipe-edit-screen', - name: 'recipeEditScreen', - builder: (context, state) => RecipeEditScreen( - recipeID: state.extra as String, - ), - ), GoRoute( path: '/profile-screen', name: 'profileScreen', diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index 7bc58da5..95ca1af9 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -46,7 +46,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/profile/services/profile_service.dart'; -import 'package:eatvio_app/features/recipes/presentation/bloc/recipe_create_cubit.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; import 'package:eatvio_app/features/recipes/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/recipes/services/recipe_service.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart'; @@ -75,7 +75,6 @@ Future init() async { sl.registerFactory(() => CalendarCubit(calendarRepository: sl())); sl.registerFactory(() => ProfileCubit(profileRepository: sl(), chatRepository: sl())); sl.registerFactory(() => profilePlans.PlansCubit(plansRepository: sl())); - sl.registerFactory(() => RecipeCreateCubit(recipeRepository: sl())); sl.registerFactory(() => DietsCubit(feedRepository: sl(), dietsRepository: sl())); sl.registerFactory(() => CommentsCubit(feedRepository: sl())); sl.registerFactory(() => ChatSelectCubit(chatRepository: sl())); @@ -90,6 +89,7 @@ Future init() async { sl.registerFactory(() => CreatePlanCubit()); sl.registerFactory(() => FeedProfileCubit(profileRepository: sl())); sl.registerFactory(() => AboutPlanCubit(planRepository: sl())); + sl.registerFactory(() => CreateRecipeCubit()); // Repository sl.registerLazySingleton(() => ChatRepository(chatService: sl())); diff --git a/lib/core/widgets/custom_drop_down.dart b/lib/core/widgets/custom_drop_down.dart index 887f4597..10edb929 100644 --- a/lib/core/widgets/custom_drop_down.dart +++ b/lib/core/widgets/custom_drop_down.dart @@ -64,11 +64,6 @@ class CustomDropDown extends StatelessWidget { value: value, isDense: true, isExpanded: true, - style: TextStyle( - fontSize: 16, - color: Colors.black, - fontWeight: FontWeight.w400, - ), items: items?.map((SelectionPopupModel item) { return DropdownMenuItem( value: item, diff --git a/lib/core/widgets/custom_text_form_field.dart b/lib/core/widgets/custom_text_form_field.dart index b9588f68..85b9c7ce 100644 --- a/lib/core/widgets/custom_text_form_field.dart +++ b/lib/core/widgets/custom_text_form_field.dart @@ -38,9 +38,11 @@ class CustomTextFormField extends StatelessWidget { this.expands = false, this.textAlignVertical, this.counter = const SizedBox.shrink(), + this.enabled = true, super.key, this.hasError = false, }); + final Widget? counter; final Alignment? alignment; final double? width; @@ -76,11 +78,14 @@ class CustomTextFormField extends StatelessWidget { final bool expands; final TextAlignVertical? textAlignVertical; final bool hasError; + final bool enabled; + @override Widget build(BuildContext context) { return Align( alignment: alignment ?? Alignment.center, child: TextFormField( + enabled: enabled, textAlignVertical: textAlignVertical, expands: expands, minLines: minLines, @@ -111,6 +116,7 @@ class CustomTextFormField extends StatelessWidget { prefixIcon: prefix, prefixIconConstraints: prefixConstraints, suffixIcon: suffix, + floatingLabelBehavior: FloatingLabelBehavior.always, suffixStyle: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index c6c8e291..4fe82667 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -34,8 +34,9 @@ class _DiaryScreenState extends State { children: [ CalendarWidget(), if (state is DiaryStateLoading) const Loader(), - if (state is DiaryStateLoaded) MealWidget(), CaloriesChartWidget(), + const SizedBox(height: 12), + if (state is DiaryStateLoaded) MealWidget(), ], ), ), diff --git a/lib/features/diary/presentation/widgets/calories_chart_widget.dart b/lib/features/diary/presentation/widgets/calories_chart_widget.dart index d59d3b24..b5a6c625 100644 --- a/lib/features/diary/presentation/widgets/calories_chart_widget.dart +++ b/lib/features/diary/presentation/widgets/calories_chart_widget.dart @@ -13,7 +13,6 @@ class CaloriesChartWidget extends StatelessWidget { if (state is DiaryStateLoaded) { return Container( padding: const EdgeInsets.all(16), - margin: const EdgeInsets.only(top: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: Colors.white, diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 0459426e..24ec166f 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -152,6 +152,9 @@ class _FeedScreenState extends State { _searchBar(), SizedBox(width: 12), switch (_selectedChipIndex) { + 1 => _addItemButton( + onTap: () => GoRouter.of(context).pushNamed('recipeCreateScreen'), + ), 2 => _addItemButton( onTap: state.profile?.role == 'consultant' ? () => GoRouter.of(context).pushNamed(CreateDietScreen.name) : null, ), diff --git a/lib/features/recipes/entities/recipe_create_entity.dart b/lib/features/recipes/entities/recipe_create_entity.dart index e69de29b..43a44a55 100644 --- a/lib/features/recipes/entities/recipe_create_entity.dart +++ b/lib/features/recipes/entities/recipe_create_entity.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recipe_create_entity.freezed.dart'; + +enum TypeOfRecipe { + first_meals, + second_meals, + salads, + snacks, + sweets, + drinks, + other, +} + +@freezed +class RecipeCreateEntity with _$RecipeCreateEntity { + const factory RecipeCreateEntity({ + String? title, + TypeOfRecipe? typeOfRecipe, + int? countOfPortions, + double? energyInOnePortion, + String? totalCookTime, // hh:mm + String? activeCookTime, // hh:mm + String? description, + File? image, + String? videoUrl, + required List ingredients, + required List steps, + required List tags, // tags for search + }) = _RecipeCreateEntity; +} + +@freezed +class IngredientEntity with _$IngredientEntity { + const factory IngredientEntity({ + required String name, + required String unit, + required String portion, + }) = _IngredientEntity; +} + +@freezed +class StepEntity with _$StepEntity { + const factory StepEntity({ + String? description, + File? image, + }) = _StepEntity; +} diff --git a/lib/features/recipes/entities/recipe_create_entity.freezed.dart b/lib/features/recipes/entities/recipe_create_entity.freezed.dart new file mode 100644 index 00000000..e42db8f4 --- /dev/null +++ b/lib/features/recipes/entities/recipe_create_entity.freezed.dart @@ -0,0 +1,722 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recipe_create_entity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$RecipeCreateEntity { + String? get title => throw _privateConstructorUsedError; + TypeOfRecipe? get typeOfRecipe => throw _privateConstructorUsedError; + int? get countOfPortions => throw _privateConstructorUsedError; + double? get energyInOnePortion => throw _privateConstructorUsedError; + String? get totalCookTime => throw _privateConstructorUsedError; // hh:mm + String? get activeCookTime => throw _privateConstructorUsedError; // hh:mm + String? get description => throw _privateConstructorUsedError; + File? get image => throw _privateConstructorUsedError; + String? get videoUrl => throw _privateConstructorUsedError; + List get ingredients => throw _privateConstructorUsedError; + List get steps => throw _privateConstructorUsedError; + List get tags => throw _privateConstructorUsedError; + + /// Create a copy of RecipeCreateEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $RecipeCreateEntityCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RecipeCreateEntityCopyWith<$Res> { + factory $RecipeCreateEntityCopyWith( + RecipeCreateEntity value, $Res Function(RecipeCreateEntity) then) = + _$RecipeCreateEntityCopyWithImpl<$Res, RecipeCreateEntity>; + @useResult + $Res call( + {String? title, + TypeOfRecipe? typeOfRecipe, + int? countOfPortions, + double? energyInOnePortion, + String? totalCookTime, + String? activeCookTime, + String? description, + File? image, + String? videoUrl, + List ingredients, + List steps, + List tags}); +} + +/// @nodoc +class _$RecipeCreateEntityCopyWithImpl<$Res, $Val extends RecipeCreateEntity> + implements $RecipeCreateEntityCopyWith<$Res> { + _$RecipeCreateEntityCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RecipeCreateEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? title = freezed, + Object? typeOfRecipe = freezed, + Object? countOfPortions = freezed, + Object? energyInOnePortion = freezed, + Object? totalCookTime = freezed, + Object? activeCookTime = freezed, + Object? description = freezed, + Object? image = freezed, + Object? videoUrl = freezed, + Object? ingredients = null, + Object? steps = null, + Object? tags = null, + }) { + return _then(_value.copyWith( + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + typeOfRecipe: freezed == typeOfRecipe + ? _value.typeOfRecipe + : typeOfRecipe // ignore: cast_nullable_to_non_nullable + as TypeOfRecipe?, + countOfPortions: freezed == countOfPortions + ? _value.countOfPortions + : countOfPortions // ignore: cast_nullable_to_non_nullable + as int?, + energyInOnePortion: freezed == energyInOnePortion + ? _value.energyInOnePortion + : energyInOnePortion // ignore: cast_nullable_to_non_nullable + as double?, + totalCookTime: freezed == totalCookTime + ? _value.totalCookTime + : totalCookTime // ignore: cast_nullable_to_non_nullable + as String?, + activeCookTime: freezed == activeCookTime + ? _value.activeCookTime + : activeCookTime // ignore: cast_nullable_to_non_nullable + as String?, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + image: freezed == image + ? _value.image + : image // ignore: cast_nullable_to_non_nullable + as File?, + videoUrl: freezed == videoUrl + ? _value.videoUrl + : videoUrl // ignore: cast_nullable_to_non_nullable + as String?, + ingredients: null == ingredients + ? _value.ingredients + : ingredients // ignore: cast_nullable_to_non_nullable + as List, + steps: null == steps + ? _value.steps + : steps // ignore: cast_nullable_to_non_nullable + as List, + tags: null == tags + ? _value.tags + : tags // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RecipeCreateEntityImplCopyWith<$Res> + implements $RecipeCreateEntityCopyWith<$Res> { + factory _$$RecipeCreateEntityImplCopyWith(_$RecipeCreateEntityImpl value, + $Res Function(_$RecipeCreateEntityImpl) then) = + __$$RecipeCreateEntityImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String? title, + TypeOfRecipe? typeOfRecipe, + int? countOfPortions, + double? energyInOnePortion, + String? totalCookTime, + String? activeCookTime, + String? description, + File? image, + String? videoUrl, + List ingredients, + List steps, + List tags}); +} + +/// @nodoc +class __$$RecipeCreateEntityImplCopyWithImpl<$Res> + extends _$RecipeCreateEntityCopyWithImpl<$Res, _$RecipeCreateEntityImpl> + implements _$$RecipeCreateEntityImplCopyWith<$Res> { + __$$RecipeCreateEntityImplCopyWithImpl(_$RecipeCreateEntityImpl _value, + $Res Function(_$RecipeCreateEntityImpl) _then) + : super(_value, _then); + + /// Create a copy of RecipeCreateEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? title = freezed, + Object? typeOfRecipe = freezed, + Object? countOfPortions = freezed, + Object? energyInOnePortion = freezed, + Object? totalCookTime = freezed, + Object? activeCookTime = freezed, + Object? description = freezed, + Object? image = freezed, + Object? videoUrl = freezed, + Object? ingredients = null, + Object? steps = null, + Object? tags = null, + }) { + return _then(_$RecipeCreateEntityImpl( + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + typeOfRecipe: freezed == typeOfRecipe + ? _value.typeOfRecipe + : typeOfRecipe // ignore: cast_nullable_to_non_nullable + as TypeOfRecipe?, + countOfPortions: freezed == countOfPortions + ? _value.countOfPortions + : countOfPortions // ignore: cast_nullable_to_non_nullable + as int?, + energyInOnePortion: freezed == energyInOnePortion + ? _value.energyInOnePortion + : energyInOnePortion // ignore: cast_nullable_to_non_nullable + as double?, + totalCookTime: freezed == totalCookTime + ? _value.totalCookTime + : totalCookTime // ignore: cast_nullable_to_non_nullable + as String?, + activeCookTime: freezed == activeCookTime + ? _value.activeCookTime + : activeCookTime // ignore: cast_nullable_to_non_nullable + as String?, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + image: freezed == image + ? _value.image + : image // ignore: cast_nullable_to_non_nullable + as File?, + videoUrl: freezed == videoUrl + ? _value.videoUrl + : videoUrl // ignore: cast_nullable_to_non_nullable + as String?, + ingredients: null == ingredients + ? _value._ingredients + : ingredients // ignore: cast_nullable_to_non_nullable + as List, + steps: null == steps + ? _value._steps + : steps // ignore: cast_nullable_to_non_nullable + as List, + tags: null == tags + ? _value._tags + : tags // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$RecipeCreateEntityImpl implements _RecipeCreateEntity { + const _$RecipeCreateEntityImpl( + {this.title, + this.typeOfRecipe, + this.countOfPortions, + this.energyInOnePortion, + this.totalCookTime, + this.activeCookTime, + this.description, + this.image, + this.videoUrl, + required final List ingredients, + required final List steps, + required final List tags}) + : _ingredients = ingredients, + _steps = steps, + _tags = tags; + + @override + final String? title; + @override + final TypeOfRecipe? typeOfRecipe; + @override + final int? countOfPortions; + @override + final double? energyInOnePortion; + @override + final String? totalCookTime; +// hh:mm + @override + final String? activeCookTime; +// hh:mm + @override + final String? description; + @override + final File? image; + @override + final String? videoUrl; + final List _ingredients; + @override + List get ingredients { + if (_ingredients is EqualUnmodifiableListView) return _ingredients; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_ingredients); + } + + final List _steps; + @override + List get steps { + if (_steps is EqualUnmodifiableListView) return _steps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_steps); + } + + final List _tags; + @override + List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); + } + + @override + String toString() { + return 'RecipeCreateEntity(title: $title, typeOfRecipe: $typeOfRecipe, countOfPortions: $countOfPortions, energyInOnePortion: $energyInOnePortion, totalCookTime: $totalCookTime, activeCookTime: $activeCookTime, description: $description, image: $image, videoUrl: $videoUrl, ingredients: $ingredients, steps: $steps, tags: $tags)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RecipeCreateEntityImpl && + (identical(other.title, title) || other.title == title) && + (identical(other.typeOfRecipe, typeOfRecipe) || + other.typeOfRecipe == typeOfRecipe) && + (identical(other.countOfPortions, countOfPortions) || + other.countOfPortions == countOfPortions) && + (identical(other.energyInOnePortion, energyInOnePortion) || + other.energyInOnePortion == energyInOnePortion) && + (identical(other.totalCookTime, totalCookTime) || + other.totalCookTime == totalCookTime) && + (identical(other.activeCookTime, activeCookTime) || + other.activeCookTime == activeCookTime) && + (identical(other.description, description) || + other.description == description) && + (identical(other.image, image) || other.image == image) && + (identical(other.videoUrl, videoUrl) || + other.videoUrl == videoUrl) && + const DeepCollectionEquality() + .equals(other._ingredients, _ingredients) && + const DeepCollectionEquality().equals(other._steps, _steps) && + const DeepCollectionEquality().equals(other._tags, _tags)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + title, + typeOfRecipe, + countOfPortions, + energyInOnePortion, + totalCookTime, + activeCookTime, + description, + image, + videoUrl, + const DeepCollectionEquality().hash(_ingredients), + const DeepCollectionEquality().hash(_steps), + const DeepCollectionEquality().hash(_tags)); + + /// Create a copy of RecipeCreateEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$RecipeCreateEntityImplCopyWith<_$RecipeCreateEntityImpl> get copyWith => + __$$RecipeCreateEntityImplCopyWithImpl<_$RecipeCreateEntityImpl>( + this, _$identity); +} + +abstract class _RecipeCreateEntity implements RecipeCreateEntity { + const factory _RecipeCreateEntity( + {final String? title, + final TypeOfRecipe? typeOfRecipe, + final int? countOfPortions, + final double? energyInOnePortion, + final String? totalCookTime, + final String? activeCookTime, + final String? description, + final File? image, + final String? videoUrl, + required final List ingredients, + required final List steps, + required final List tags}) = _$RecipeCreateEntityImpl; + + @override + String? get title; + @override + TypeOfRecipe? get typeOfRecipe; + @override + int? get countOfPortions; + @override + double? get energyInOnePortion; + @override + String? get totalCookTime; // hh:mm + @override + String? get activeCookTime; // hh:mm + @override + String? get description; + @override + File? get image; + @override + String? get videoUrl; + @override + List get ingredients; + @override + List get steps; + @override + List get tags; + + /// Create a copy of RecipeCreateEntity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$RecipeCreateEntityImplCopyWith<_$RecipeCreateEntityImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$IngredientEntity { + String get name => throw _privateConstructorUsedError; + String get unit => throw _privateConstructorUsedError; + String get portion => throw _privateConstructorUsedError; + + /// Create a copy of IngredientEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $IngredientEntityCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $IngredientEntityCopyWith<$Res> { + factory $IngredientEntityCopyWith( + IngredientEntity value, $Res Function(IngredientEntity) then) = + _$IngredientEntityCopyWithImpl<$Res, IngredientEntity>; + @useResult + $Res call({String name, String unit, String portion}); +} + +/// @nodoc +class _$IngredientEntityCopyWithImpl<$Res, $Val extends IngredientEntity> + implements $IngredientEntityCopyWith<$Res> { + _$IngredientEntityCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of IngredientEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? unit = null, + Object? portion = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + unit: null == unit + ? _value.unit + : unit // ignore: cast_nullable_to_non_nullable + as String, + portion: null == portion + ? _value.portion + : portion // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$IngredientEntityImplCopyWith<$Res> + implements $IngredientEntityCopyWith<$Res> { + factory _$$IngredientEntityImplCopyWith(_$IngredientEntityImpl value, + $Res Function(_$IngredientEntityImpl) then) = + __$$IngredientEntityImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String unit, String portion}); +} + +/// @nodoc +class __$$IngredientEntityImplCopyWithImpl<$Res> + extends _$IngredientEntityCopyWithImpl<$Res, _$IngredientEntityImpl> + implements _$$IngredientEntityImplCopyWith<$Res> { + __$$IngredientEntityImplCopyWithImpl(_$IngredientEntityImpl _value, + $Res Function(_$IngredientEntityImpl) _then) + : super(_value, _then); + + /// Create a copy of IngredientEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? unit = null, + Object? portion = null, + }) { + return _then(_$IngredientEntityImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + unit: null == unit + ? _value.unit + : unit // ignore: cast_nullable_to_non_nullable + as String, + portion: null == portion + ? _value.portion + : portion // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$IngredientEntityImpl implements _IngredientEntity { + const _$IngredientEntityImpl( + {required this.name, required this.unit, required this.portion}); + + @override + final String name; + @override + final String unit; + @override + final String portion; + + @override + String toString() { + return 'IngredientEntity(name: $name, unit: $unit, portion: $portion)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$IngredientEntityImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.unit, unit) || other.unit == unit) && + (identical(other.portion, portion) || other.portion == portion)); + } + + @override + int get hashCode => Object.hash(runtimeType, name, unit, portion); + + /// Create a copy of IngredientEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$IngredientEntityImplCopyWith<_$IngredientEntityImpl> get copyWith => + __$$IngredientEntityImplCopyWithImpl<_$IngredientEntityImpl>( + this, _$identity); +} + +abstract class _IngredientEntity implements IngredientEntity { + const factory _IngredientEntity( + {required final String name, + required final String unit, + required final String portion}) = _$IngredientEntityImpl; + + @override + String get name; + @override + String get unit; + @override + String get portion; + + /// Create a copy of IngredientEntity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$IngredientEntityImplCopyWith<_$IngredientEntityImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$StepEntity { + String? get description => throw _privateConstructorUsedError; + File? get image => throw _privateConstructorUsedError; + + /// Create a copy of StepEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $StepEntityCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $StepEntityCopyWith<$Res> { + factory $StepEntityCopyWith( + StepEntity value, $Res Function(StepEntity) then) = + _$StepEntityCopyWithImpl<$Res, StepEntity>; + @useResult + $Res call({String? description, File? image}); +} + +/// @nodoc +class _$StepEntityCopyWithImpl<$Res, $Val extends StepEntity> + implements $StepEntityCopyWith<$Res> { + _$StepEntityCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of StepEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? description = freezed, + Object? image = freezed, + }) { + return _then(_value.copyWith( + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + image: freezed == image + ? _value.image + : image // ignore: cast_nullable_to_non_nullable + as File?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$StepEntityImplCopyWith<$Res> + implements $StepEntityCopyWith<$Res> { + factory _$$StepEntityImplCopyWith( + _$StepEntityImpl value, $Res Function(_$StepEntityImpl) then) = + __$$StepEntityImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String? description, File? image}); +} + +/// @nodoc +class __$$StepEntityImplCopyWithImpl<$Res> + extends _$StepEntityCopyWithImpl<$Res, _$StepEntityImpl> + implements _$$StepEntityImplCopyWith<$Res> { + __$$StepEntityImplCopyWithImpl( + _$StepEntityImpl _value, $Res Function(_$StepEntityImpl) _then) + : super(_value, _then); + + /// Create a copy of StepEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? description = freezed, + Object? image = freezed, + }) { + return _then(_$StepEntityImpl( + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + image: freezed == image + ? _value.image + : image // ignore: cast_nullable_to_non_nullable + as File?, + )); + } +} + +/// @nodoc + +class _$StepEntityImpl implements _StepEntity { + const _$StepEntityImpl({this.description, this.image}); + + @override + final String? description; + @override + final File? image; + + @override + String toString() { + return 'StepEntity(description: $description, image: $image)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$StepEntityImpl && + (identical(other.description, description) || + other.description == description) && + (identical(other.image, image) || other.image == image)); + } + + @override + int get hashCode => Object.hash(runtimeType, description, image); + + /// Create a copy of StepEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$StepEntityImplCopyWith<_$StepEntityImpl> get copyWith => + __$$StepEntityImplCopyWithImpl<_$StepEntityImpl>(this, _$identity); +} + +abstract class _StepEntity implements StepEntity { + const factory _StepEntity({final String? description, final File? image}) = + _$StepEntityImpl; + + @override + String? get description; + @override + File? get image; + + /// Create a copy of StepEntity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$StepEntityImplCopyWith<_$StepEntityImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart b/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart new file mode 100644 index 00000000..03253f26 --- /dev/null +++ b/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart @@ -0,0 +1,219 @@ +import 'dart:io'; + +import 'package:eatvio_app/features/recipes/entities/recipe_create_entity.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CreateRecipeCubit extends Cubit { + CreateRecipeCubit() + : super( + CreateRecipeState( + RecipeCreateEntity( + ingredients: [], + steps: [], + tags: [], + ), + ), + ); + + addStep() { + emit( + CreateRecipeState( + state.entity.copyWith( + steps: [ + ...state.entity.steps, + StepEntity(), + ], + ), + ), + ); + } + + removeStep(StepEntity step) { + List steps = [...state.entity.steps]; + steps.remove(step); + + emit( + CreateRecipeState(state.entity.copyWith(steps: steps)), + ); + } + + addIngredient(IngredientEntity entity) { + emit( + CreateRecipeState( + state.entity.copyWith( + ingredients: [ + ...state.entity.ingredients, + entity, + ], + ), + ), + ); + } + + removeIngredient(int index) { + List ingredients = [...state.entity.ingredients]; + ingredients.removeAt(index); + + emit( + CreateRecipeState( + state.entity.copyWith( + ingredients: ingredients, + ), + ), + ); + } + + replaceIngredientPosition(int oldIndex, int newIndex) { + List ingredients = [...state.entity.ingredients]; + final IngredientEntity item = ingredients.removeAt(oldIndex); + ingredients.insert(newIndex, item); + emit( + CreateRecipeState( + state.entity.copyWith( + ingredients: ingredients, + ), + ), + ); + } + + replaceTitle(String title) { + emit( + CreateRecipeState( + state.entity.copyWith( + title: title, + ), + ), + ); + } + + replaceDescription(String description) { + emit( + CreateRecipeState( + state.entity.copyWith( + description: description, + ), + ), + ); + } + + changeType(TypeOfRecipe typeOfRecipe) { + emit( + CreateRecipeState( + state.entity.copyWith( + typeOfRecipe: typeOfRecipe, + ), + ), + ); + } + + addTag(String tag) { + emit( + CreateRecipeState( + state.entity.copyWith( + tags: [ + ...state.entity.tags, + tag, + ], + ), + ), + ); + } + + removeTag(String tag) { + List tags = [...state.entity.tags]; + tags.remove(tag); + + emit( + CreateRecipeState( + state.entity.copyWith( + tags: tags, + ), + ), + ); + } + + replacePortionsCount(int count) { + emit( + CreateRecipeState( + state.entity.copyWith( + countOfPortions: count, + ), + ), + ); + } + + replaceEnegryInPortion(double energy) { + emit( + CreateRecipeState( + state.entity.copyWith( + energyInOnePortion: energy, + ), + ), + ); + } + + replaceTotalTime(String time) { + emit( + CreateRecipeState( + state.entity.copyWith( + totalCookTime: time, + ), + ), + ); + } + + replaceActiveTime(String time) { + emit( + CreateRecipeState( + state.entity.copyWith( + activeCookTime: time, + ), + ), + ); + } + + replaceVideoUrl(String videoUrl) { + emit( + CreateRecipeState( + state.entity.copyWith( + videoUrl: videoUrl, + ), + ), + ); + } + + replaceImage(File? image) { + emit( + CreateRecipeState( + state.entity.copyWith( + image: image, + ), + ), + ); + } + + replaceDescriptionInStep(String description, int index) { + List steps = [...state.entity.steps]; + steps[index] = steps[index].copyWith(description: description); + emit( + CreateRecipeState( + state.entity.copyWith( + steps: steps, + ), + ), + ); + } + + replaceImageInStep(File? image, int index) { + List steps = [...state.entity.steps]; + steps[index] = steps[index].copyWith(image: image); + emit( + CreateRecipeState( + state.entity.copyWith( + steps: steps, + ), + ), + ); + } +} diff --git a/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart b/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart new file mode 100644 index 00000000..19adabf8 --- /dev/null +++ b/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart @@ -0,0 +1,11 @@ +import 'package:eatvio_app/features/recipes/entities/recipe_create_entity.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'create_recipe_state.freezed.dart'; + +@freezed +class CreateRecipeState with _$CreateRecipeState { + const factory CreateRecipeState( + RecipeCreateEntity entity, + ) = _CreateRecipeState; +} diff --git a/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.freezed.dart b/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.freezed.dart new file mode 100644 index 00000000..b1661b76 --- /dev/null +++ b/lib/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.freezed.dart @@ -0,0 +1,160 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'create_recipe_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$CreateRecipeState { + RecipeCreateEntity get entity => throw _privateConstructorUsedError; + + /// Create a copy of CreateRecipeState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $CreateRecipeStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CreateRecipeStateCopyWith<$Res> { + factory $CreateRecipeStateCopyWith( + CreateRecipeState value, $Res Function(CreateRecipeState) then) = + _$CreateRecipeStateCopyWithImpl<$Res, CreateRecipeState>; + @useResult + $Res call({RecipeCreateEntity entity}); + + $RecipeCreateEntityCopyWith<$Res> get entity; +} + +/// @nodoc +class _$CreateRecipeStateCopyWithImpl<$Res, $Val extends CreateRecipeState> + implements $CreateRecipeStateCopyWith<$Res> { + _$CreateRecipeStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CreateRecipeState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? entity = null, + }) { + return _then(_value.copyWith( + entity: null == entity + ? _value.entity + : entity // ignore: cast_nullable_to_non_nullable + as RecipeCreateEntity, + ) as $Val); + } + + /// Create a copy of CreateRecipeState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $RecipeCreateEntityCopyWith<$Res> get entity { + return $RecipeCreateEntityCopyWith<$Res>(_value.entity, (value) { + return _then(_value.copyWith(entity: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$CreateRecipeStateImplCopyWith<$Res> + implements $CreateRecipeStateCopyWith<$Res> { + factory _$$CreateRecipeStateImplCopyWith(_$CreateRecipeStateImpl value, + $Res Function(_$CreateRecipeStateImpl) then) = + __$$CreateRecipeStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({RecipeCreateEntity entity}); + + @override + $RecipeCreateEntityCopyWith<$Res> get entity; +} + +/// @nodoc +class __$$CreateRecipeStateImplCopyWithImpl<$Res> + extends _$CreateRecipeStateCopyWithImpl<$Res, _$CreateRecipeStateImpl> + implements _$$CreateRecipeStateImplCopyWith<$Res> { + __$$CreateRecipeStateImplCopyWithImpl(_$CreateRecipeStateImpl _value, + $Res Function(_$CreateRecipeStateImpl) _then) + : super(_value, _then); + + /// Create a copy of CreateRecipeState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? entity = null, + }) { + return _then(_$CreateRecipeStateImpl( + null == entity + ? _value.entity + : entity // ignore: cast_nullable_to_non_nullable + as RecipeCreateEntity, + )); + } +} + +/// @nodoc + +class _$CreateRecipeStateImpl implements _CreateRecipeState { + const _$CreateRecipeStateImpl(this.entity); + + @override + final RecipeCreateEntity entity; + + @override + String toString() { + return 'CreateRecipeState(entity: $entity)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CreateRecipeStateImpl && + (identical(other.entity, entity) || other.entity == entity)); + } + + @override + int get hashCode => Object.hash(runtimeType, entity); + + /// Create a copy of CreateRecipeState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CreateRecipeStateImplCopyWith<_$CreateRecipeStateImpl> get copyWith => + __$$CreateRecipeStateImplCopyWithImpl<_$CreateRecipeStateImpl>( + this, _$identity); +} + +abstract class _CreateRecipeState implements CreateRecipeState { + const factory _CreateRecipeState(final RecipeCreateEntity entity) = + _$CreateRecipeStateImpl; + + @override + RecipeCreateEntity get entity; + + /// Create a copy of CreateRecipeState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CreateRecipeStateImplCopyWith<_$CreateRecipeStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/recipes/presentation/pages/recipe_create_screen.dart b/lib/features/recipes/presentation/pages/recipe_create_screen.dart index bc95e6c3..efeace26 100644 --- a/lib/features/recipes/presentation/pages/recipe_create_screen.dart +++ b/lib/features/recipes/presentation/pages/recipe_create_screen.dart @@ -1,508 +1,71 @@ -import 'dart:io'; -import 'package:eatvio_app/core/theme/app_decoration.dart'; -import 'package:eatvio_app/core/widgets/custom_drop_down.dart'; -import 'package:eatvio_app/core/widgets/loader.dart'; -import 'package:eatvio_app/features/recipes/models/product.dart'; -import 'package:eatvio_app/features/recipes/models/ingredient.dart'; -import 'package:eatvio_app/features/recipes/models/recipe_model.dart'; -import 'package:eatvio_app/features/recipes/models/step.dart' as step; -import 'package:eatvio_app/features/recipes/presentation/bloc/recipe_create_cubit.dart'; -import 'package:eatvio_app/features/recipes/presentation/bloc/recipe_create_state.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/custom_text_form_field.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/info_about_recipe_widget.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/ingredients_widget.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/steps_widget.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; -class RecipeCreateScreen extends StatelessWidget { - RecipeCreateScreen({Key? key}) : super(key: key); +class RecipeCreateScreen extends StatefulWidget { + const RecipeCreateScreen({super.key}); + @override + State createState() => _RecipeCreateScreenState(); +} + +class _RecipeCreateScreenState extends State { + @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => sl()..loadRecipeCreate(), - child: BlocBuilder( - builder: (context, state) { - if (state is RecipeCreateStateLoaded) { - return _buildRecipeCreate(context, state); - } else if (state is RecipeCreateStateLoading) { - return Scaffold( - body: const Loader(), - ); - } else if (state is RecipeCreateStateError) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: Text(state.errorMessage), - ), - ), - ); - } else - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: Text("Неопределенная ошибка"), - ), - ), - ); - }, - ), - ); - } - - Widget _buildRecipeCreate(BuildContext context, RecipeCreateStateLoaded state) { - List _options = state.autocompleteList.map((Product item) => item.name).toList(); - return Scaffold( - appBar: AppBar( - actions: [ - InkWell( - borderRadius: BorderRadius.circular(16), - child: Ink( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: 4), - Text( - 'Новый рецепт', - style: TextStyle( - color: Colors.black, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1, - ), - ), - ], - ), + return BlocProvider( + create: (BuildContext context) => sl(), + child: Scaffold( + backgroundColor: Color(0xFFF0F2F5), + appBar: AppBar( + title: Text('Добавление рецепта'), + centerTitle: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(12), ), ), - ], - ), - backgroundColor: Color.fromARGB(255, 240, 242, 245), - resizeToAvoidBottomInset: true, - body: SizedBox( - width: double.maxFinite, - child: SingleChildScrollView( + ), + body: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.only(top: 8, bottom: 32), child: Column( + spacing: 16, children: [ - Container( - padding: EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), + InfoAboutRecipeWidget(), + IngredientsWidget(), + StepsWidget(), + InkWell( + onTap: () {}, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - 32, decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.all(Radius.circular(8)), + color: appTheme.blueA400, + borderRadius: BorderRadius.circular(20), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Заголовок'), - SizedBox(height: 8), - CustomTextFormField( - fillColor: appTheme.gray50, - controller: state.titleController, - hintText: 'Заголовок', + child: Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.center, + child: Text( + 'Опубликовать', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w600, ), - SizedBox(height: 16), - Text('Тип блюда'), - SizedBox(height: 8), - CustomDropDown( - value: state.selectedType, - items: state.types, - onChanged: changeType(context), - ), - SizedBox(height: 16), - Text('Изображение'), - GestureDetector( - onTap: () async { - final pickedFile = await state.picker.pickImage(source: ImageSource.gallery); - context.read().uploadRecipeImage(pickedFile); - }, - child: state.recipeImage == null - ? Container( - height: 128, - decoration: AppDecoration.fillGray.copyWith( - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.photo_library, - color: appTheme.blueGray300, - ), - SizedBox(width: 8), - Text( - "Добавить фото", - selectionColor: appTheme.blueA400, - ), - ], - ), - ) - : Image.file(File(state.recipeImage!.path), height: 200, fit: BoxFit.cover), - ), - SizedBox(height: 16), - Text('Время приготовления'), - SizedBox(height: 8), - CustomDropDown( - items: state.cookingTimes, - value: state.selectedCookingTime, - onChanged: changeCookingTime(context), - ), - SizedBox(height: 16), - Text('Статус'), - SizedBox(height: 8), - CustomDropDown( - items: state.statuses, - value: state.selectedStatus, - onChanged: changeStatus(context), - ), - SizedBox(height: 24), - if (!state.ingredients.isEmpty) - for (int j = 0; j < state.ingredients.length; j++) _buildIngredient(state.ingredients[j]), - SizedBox(height: 24), - RawAutocomplete( - optionsBuilder: _buildOptions(_options), - fieldViewBuilder: ( - BuildContext context, - TextEditingController productEditingController, - FocusNode focusNodeName, - VoidCallback onFieldSubmitted, - ) { - return CustomTextFormField( - hintText: 'Введите название продукта', - controller: TextEditingController(text: state.productNameController.text), - focusNode: focusNodeName, - onChanged: getAutocomplete(context, focusNodeName), - ); - }, - optionsViewBuilder: ( - BuildContext context, - AutocompleteOnSelected onSelected, - Iterable options, - ) { - return Align( - alignment: Alignment.topLeft, - child: Material( - elevation: 4.0, - child: SizedBox( - height: 200.0, - child: ListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: options.length, - itemBuilder: (BuildContext context, int index) { - final String option = options.elementAt(index); - return GestureDetector( - onTap: selectProduct(onSelected, option, state), - child: ListTile( - title: Text(option), - ), - ); - }, - ), - ), - ), - ); - }, - ), - SizedBox(height: 24), - CustomTextFormField( - hintText: 'Порция', - fillColor: appTheme.gray50, - controller: state.ingredientController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - SizedBox(height: 8), - Row(children: [ - Text('гр'), - Switch( - value: state.ingredientAsItems, - onChanged: (bool value) => context.read().switchUnits(value), - activeColor: appTheme.blueA400, - inactiveThumbColor: Colors.grey, - inactiveTrackColor: Colors.grey.shade300, - ), - Text('шт'), - ],), - SizedBox(height: 8), - Container( - height: 48, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - backgroundColor: appTheme.blueA400, - ), - onPressed: () => context.read().addIngredient( - Ingredient( - name: state.productNameController.text, - portion: state.ingredientController.text, - unit: state.ingredientAsItems ? 'pcs' : 'gr', - ), - ), - child: Text( - '+', - style: theme.textTheme.titleSmall, - ), - ), - ), - SizedBox(height: 24), - Text('Описание рецепта'), - SizedBox(height: 8), - CustomTextFormField( - maxLines: 8, - minLines: 8, - controller: state.stepDescriptionController, - ), - SizedBox(height: 24), - for (int i = 0; i < state.numberOfSteps; i++) _buildStep(context, i, state), - SizedBox(height: 24), - Container( - height: 48, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - backgroundColor: appTheme.blueA400, - ), - onPressed: () => context.read().addStep(), - child: Text( - '+', - style: theme.textTheme.titleSmall, - ), - ), - ), - SizedBox(height: 8), - Container( - height: 48, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - backgroundColor: Colors.grey, - ), - onPressed: () => context.read().removeStep(), - child: Text( - '-', - style: theme.textTheme.titleSmall, - ), - ), - ), - SizedBox(height: 24), - Text('Ссылка на видео (video)'), - SizedBox(height: 8), - CustomTextFormField( - hintText: 'Введите описание шага', - fillColor: appTheme.gray50, - controller: state.videoLinkController, - ), - SizedBox(height: 24), - Container( - height: 48, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - backgroundColor: appTheme.blueA400, - ), - child: Text( - 'Опубликовать', - style: theme.textTheme.titleSmall, - ), - onPressed: () => context.read().publishRecipe(createRecipeFromState(state)), - ), - ), - ], - ),), + ), + ), + ), + ), ], ), ), ), ); } - - _buildStep(BuildContext context, int stepNumber, RecipeCreateStateLoaded state) { - return Column( - children: [ - Row(children: [Text('Шаг ${stepNumber + 1}'), Spacer(), Text('Всего шагов: ${state.numberOfSteps}')]), - SizedBox(height: 8), - GestureDetector( - onTap: () async { - final pickedFile = await state.picker.pickImage(source: ImageSource.gallery); - context.read().uploadStepImage(pickedFile, stepNumber); - }, - child: state.stepImages[stepNumber] == null - ? Container( - height: 128, - decoration: AppDecoration.fillGray.copyWith( - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.photo_library, - color: appTheme.blueGray300, - ), - SizedBox(width: 8), - Text( - "Добавить фото", - selectionColor: appTheme.blueA400, - ), - SizedBox(height: 8), - ], - ), - ) - : Image.file(File(state.stepImages[stepNumber]!.path), height: 150, fit: BoxFit.cover),), - SizedBox(height: 16), - CustomTextFormField( - hintText: 'Введите описание шага', - maxLines: 8, - minLines: 8, - fillColor: appTheme.gray50, - controller: state.stepControllers[stepNumber], - ), - SizedBox(height: 24), - ], - ); - } - - _buildIngredient(Ingredient ingredient) { - return Container( - margin: EdgeInsets.symmetric(horizontal: 12), - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - decoration: AppDecoration.fillGray.copyWith( - borderRadius: BorderRadiusStyle.roundedBorder5, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.only( - left: 8, - top: 8, - ), - child: Text( - ingredient.name.length > 8 ? ingredient.name.substring(0, 8) : ingredient.name, - style: CustomTextStyles.titleMedium16, - ), - ), - Padding( - padding: EdgeInsets.only( - left: 24, - top: 4, - bottom: 4, - ), - child: Text( - ingredient.portion.toString() + ' ' + ingredient.unit.toString(), - ), - ), - Spacer(), - GestureDetector( - // onTap: removeIngredient(context, ingredient), - child: Padding( - padding: const EdgeInsets.only(right: 10), - child: Icon( - Icons.close, - color: appTheme.blueGray300, - ), - ), - ), - ], - ), - ); - } - - _buildOptions(List _options) { - return (TextEditingValue textEditingValue) { - return _options.where((String option) { - return true; - }); - }; - } - - selectProduct(AutocompleteOnSelected onSelected, String option, RecipeCreateStateLoaded state) { - state.productNameController.text = option; - return () { - onSelected(option); - }; - } - - getAutocomplete(BuildContext context, FocusNode focusNode) { - return (value) { - if (value.length > 2) { - context.read().inputProduct(value); - } - }; - } - - changeType(BuildContext context) { - return (value) { - context.read().changeType(value); - }; - } - - changeStatus(BuildContext context) { - return (value) { - context.read().changeStatus(value); - }; - } - - changeCookingTime(BuildContext context) { - return (value) { - context.read().changeCookingTime(value); - }; - } - - RecipeModel createRecipeFromState(RecipeCreateStateLoaded state) { - List ingredients = state.ingredients; - - List steps = []; - for (int i = 0; i < state.stepControllers.length; i++) { - if (state.stepControllers[i].text.isNotEmpty) { - steps.add(step.Step( - description: state.stepControllers[i].text, - image: state.stepImages[i]?.path, - ),); - } - } - - final RecipeModel recipe = RecipeModel( - ingredients: ingredients, - steps: steps, - state: state.selectedStatus.value, - type: state.selectedType.value, - cooking_time: state.selectedCookingTime.value, - description: state.stepDescriptionController.text, - title: state.titleController.text, - image: state.recipeImage?.path, - video_url: state.videoLinkController.text.isNotEmpty ? state.videoLinkController.text : null, - ); - - return recipe; - } } diff --git a/lib/features/recipes/presentation/widgets/image_widget.dart b/lib/features/recipes/presentation/widgets/image_widget.dart new file mode 100644 index 00000000..2521ff69 --- /dev/null +++ b/lib/features/recipes/presentation/widgets/image_widget.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:image_picker/image_picker.dart'; + +class ImageWidget extends StatefulWidget { + const ImageWidget({super.key}); + + @override + State createState() => _ImageWidgetState(); +} + +class _ImageWidgetState extends State { + XFile? image; + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + _showImagePicker(); + }, + child: image == null + ? SvgPicture.asset( + width: MediaQuery.of(context).size.width - 32, + 'assets/icons/upload_photo_of_portion.svg', + ) + : Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.file( + File(image!.path), + ), + ), + Positioned( + right: 16, + top: 16, + child: GestureDetector( + onTap: () { + setState(() { + image = null; + }); + createRecipeCubit.replaceImage(null); + }, + child: Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Icon(Icons.close_rounded), + ), + ), + ), + ], + ), + ); + } + + _showImagePicker() { + showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.camera); + setState(() {}); + if (image != null) { + createRecipeCubit.replaceImage(File(image!.path)); + } + Navigator.of(context).pop(); + }, + child: const Row( + children: const [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () async { + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.gallery); + setState(() {}); + if (image != null) { + createRecipeCubit.replaceImage(File(image!.path)); + } + Navigator.of(context).pop(); + }, + child: const Row( + children: const [ + Icon(Icons.photo), + SizedBox(width: 8), + Text('Галерея'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(), + isDestructiveAction: true, + child: const Text('Отмена'), + ), + ), + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/info_about_recipe_widget.dart b/lib/features/recipes/presentation/widgets/info_about_recipe_widget.dart index e69de29b..473cc04a 100644 --- a/lib/features/recipes/presentation/widgets/info_about_recipe_widget.dart +++ b/lib/features/recipes/presentation/widgets/info_about_recipe_widget.dart @@ -0,0 +1,113 @@ +import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart'; +import 'package:eatvio_app/core/widgets/custom_drop_down.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/recipes/entities/recipe_create_entity.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/image_widget.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/portion_widget.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/tags_widget.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/time_of_cook_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class InfoAboutRecipeWidget extends StatefulWidget { + const InfoAboutRecipeWidget({super.key}); + + @override + State createState() => _InfoAboutRecipeWidgetState(); +} + +class _InfoAboutRecipeWidgetState extends State { + final List items = const [ + SelectionPopupModel(title: 'Первые Блюда'), + SelectionPopupModel(title: 'Вторые Блюда'), + SelectionPopupModel(title: 'Салаты'), + SelectionPopupModel(title: 'Сладости'), + SelectionPopupModel(title: 'Напитки'), + SelectionPopupModel(title: 'Прочее'), + ]; + late SelectionPopupModel selectedType; + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + selectedType = items.first; + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return Material( + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Название'), + SizedBox(height: 4), + CustomTextFormField( + hintText: 'Например: тар-тар из говядины', + onChanged: (value) { + createRecipeCubit.replaceTitle(value); + }, + ), + const SizedBox(height: 12), + Text('Тип блюда'), + SizedBox(height: 4), + CustomDropDown( + items: items, + value: selectedType, + onChanged: (newSelectedType) { + setState(() { + selectedType = newSelectedType; + }); + createRecipeCubit.changeType( + switch (selectedType.title) { + 'Первые Блюда' => TypeOfRecipe.first_meals, + 'Вторые Блюда' => TypeOfRecipe.second_meals, + 'Салаты' => TypeOfRecipe.salads, + 'Сладости' => TypeOfRecipe.sweets, + 'Напитки' => TypeOfRecipe.drinks, + _ => TypeOfRecipe.other, + }, + ); + }, + ), + const SizedBox(height: 12), + TagsWidget(), + const SizedBox(height: 12), + PortionWidget(), + const SizedBox(height: 12), + TimeOfCookWidget(), + const SizedBox(height: 12), + Text('Описание'), + SizedBox(height: 4), + CustomTextFormField( + hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.', + minLines: 5, + maxLines: 5, + onChanged: (value) { + createRecipeCubit.replaceDescription(value); + }, + ), + const SizedBox(height: 12), + Text('Фото обложка'), + SizedBox(height: 4), + ImageWidget(), + const SizedBox(height: 12), + Text('Ссылка на видео (если есть)'), + SizedBox(height: 4), + CustomTextFormField( + hintText: 'Прикрепите ссылку на видео', + onChanged: (value) { + createRecipeCubit.replaceVideoUrl(value); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/ingredients_widget.dart b/lib/features/recipes/presentation/widgets/ingredients_widget.dart new file mode 100644 index 00000000..c8744492 --- /dev/null +++ b/lib/features/recipes/presentation/widgets/ingredients_widget.dart @@ -0,0 +1,219 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/recipes/entities/recipe_create_entity.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class IngredientsWidget extends StatefulWidget { + const IngredientsWidget({super.key}); + + @override + State createState() => _IngredientsWidgetState(); +} + +class _IngredientsWidgetState extends State { + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, CreateRecipeState state) { + return Material( + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Ингредиенты ', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + ), + ), + ), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorder: (int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + }); + createRecipeCubit.replaceIngredientPosition(oldIndex, newIndex); + }, + children: [ + for (int index = 0; index < state.entity.ingredients.length; index += 1) + ListTile( + contentPadding: EdgeInsets.symmetric(horizontal: 16), + key: Key('$index'), + leading: Icon( + Icons.drag_handle_rounded, + color: Colors.grey.shade600, + ), + title: Text(state.entity.ingredients[index].name), + subtitle: Text('${state.entity.ingredients[index].unit} ${state.entity.ingredients[index].portion == 'gr' ? 'гр' : 'шт'}'), + trailing: IconButton( + onPressed: () { + createRecipeCubit.removeIngredient(index); + }, + icon: Icon( + Icons.delete_outline_rounded, + color: Colors.grey.shade600, + ), + ), + ), + ], + ), + ListTile( + onTap: () => showModalBottomSheet( + useRootNavigator: true, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + isScrollControlled: true, + builder: (context) { + final nameController = TextEditingController(); + final grammsContorller = TextEditingController(); + final pcsController = TextEditingController(); + + return StatefulBuilder( + builder: (BuildContext context, void Function(void Function()) setStateInner) { + return Padding( + padding: const EdgeInsets.all(16) + + EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Добавление ингредиента', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: MediaQuery.of(context).size.width - 32, + child: CustomTextFormField( + autofocus: true, + controller: nameController, + hintText: 'Введите название продукта', + onChanged: (query) {}, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 20, + child: CustomTextFormField( + enabled: pcsController.text.isEmpty ? true : false, + keyboardType: TextInputType.number, + controller: grammsContorller, + hintText: pcsController.text.isEmpty ? '100' : null, + suffixText: 'гр', + onChanged: (value) { + setStateInner(() {}); + }, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 20, + child: CustomTextFormField( + enabled: grammsContorller.text.isEmpty ? true : false, + keyboardType: TextInputType.number, + controller: pcsController, + onChanged: (value) { + setStateInner(() {}); + }, + hintText: grammsContorller.text.isEmpty ? '1' : null, + suffixText: 'шт', + ), + ), + ], + ), + const SizedBox(height: 12), + InkWell( + onTap: () { + if (nameController.text.isNotEmpty && (pcsController.text.isNotEmpty || grammsContorller.text.isNotEmpty)) { + createRecipeCubit.addIngredient( + IngredientEntity( + name: nameController.text, + unit: grammsContorller.text.isEmpty ? pcsController.text : grammsContorller.text, + portion: grammsContorller.text.isEmpty ? 'pcs' : 'gr', + ), + ); + Navigator.of(context).pop(); + setState(() {}); + } + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), + child: Center( + child: Text( + 'Добавить', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + title: Text( + 'Добавить ингредиент', + style: TextStyle( + color: appTheme.blueA400, + ), + ), + leading: Icon( + Icons.add_rounded, + color: appTheme.blueA400, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/portion_widget.dart b/lib/features/recipes/presentation/widgets/portion_widget.dart new file mode 100644 index 00000000..875183ff --- /dev/null +++ b/lib/features/recipes/presentation/widgets/portion_widget.dart @@ -0,0 +1,69 @@ +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PortionWidget extends StatefulWidget { + const PortionWidget({super.key}); + + @override + State createState() => _PortionWidgetState(); +} + +class _PortionWidgetState extends State { + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Кол-во порций'), + SizedBox(height: 4), + CustomTextFormField( + hintText: '1', + onChanged: (value) { + createRecipeCubit.replacePortionsCount(int.parse(value)); + }, + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ккал в одной порции'), + SizedBox(height: 4), + CustomTextFormField( + hintText: '100', + suffixText: 'Ккал', + onChanged: (value) { + createRecipeCubit.replaceEnegryInPortion(double.parse(value)); + }, + ), + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/step_image_widget.dart b/lib/features/recipes/presentation/widgets/step_image_widget.dart new file mode 100644 index 00000000..ad4c9549 --- /dev/null +++ b/lib/features/recipes/presentation/widgets/step_image_widget.dart @@ -0,0 +1,126 @@ +import 'dart:io'; + +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:image_picker/image_picker.dart'; + +class StepImageWidget extends StatefulWidget { + final int index; + + const StepImageWidget({ + super.key, + required this.index, + }); + + @override + State createState() => _StepImageWidgetState(); +} + +class _StepImageWidgetState extends State { + XFile? image; + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + _showImagePicker(); + }, + child: image == null + ? SvgPicture.asset( + width: MediaQuery.of(context).size.width - 32, + 'assets/icons/add_step_photo.svg', + ) + : Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.file( + File(image!.path), + ), + ), + Positioned( + right: 16, + top: 16, + child: GestureDetector( + onTap: () { + setState(() { + image = null; + }); + createRecipeCubit.replaceImageInStep(null, widget.index); + }, + child: Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Icon(Icons.close_rounded), + ), + ), + ), + ], + ), + ); + } + + _showImagePicker() { + showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.camera); + setState(() {}); + if (image != null) { + createRecipeCubit.replaceImageInStep(File(image!.path), widget.index); + } + Navigator.of(context).pop(); + }, + child: const Row( + children: const [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () async { + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.gallery); + setState(() {}); + if (image != null) { + createRecipeCubit.replaceImageInStep(File(image!.path), widget.index); + } + Navigator.of(context).pop(); + }, + child: const Row( + children: const [ + Icon(Icons.photo), + SizedBox(width: 8), + Text('Галерея'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(), + isDestructiveAction: true, + child: const Text('Отмена'), + ), + ), + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/steps_widget.dart b/lib/features/recipes/presentation/widgets/steps_widget.dart new file mode 100644 index 00000000..6feb9aa6 --- /dev/null +++ b/lib/features/recipes/presentation/widgets/steps_widget.dart @@ -0,0 +1,135 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_state.dart'; +import 'package:eatvio_app/features/recipes/presentation/widgets/step_image_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class StepsWidget extends StatefulWidget { + const StepsWidget({super.key}); + + @override + State createState() => _StepsWidgetState(); +} + +class _StepsWidgetState extends State { + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, CreateRecipeState state) { + return Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Пошаговая инструкция', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + ), + ), + const SizedBox(height: 12), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: appTheme.blueA400, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Шаг ${index + 1}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + GestureDetector( + onTap: () { + context.read().removeStep(state.entity.steps[index]); + }, + child: Icon(Icons.delete_outline_rounded), + ), + ], + ), + const SizedBox(height: 12), + Text('Описание'), + SizedBox(height: 4), + CustomTextFormField( + hintText: 'Например: подготовьте ингредиенты', + maxLines: 5, + minLines: 3, + onChanged: (value) { + createRecipeCubit.replaceDescriptionInStep(value, index); + }, + ), + const SizedBox(height: 12), + Text('Фото шага'), + SizedBox(height: 4), + StepImageWidget( + index: index, + ), + ], + ), + itemCount: state.entity.steps.length, + separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 24), + ), + InkWell( + onTap: () { + context.read().addStep(); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - 32, + decoration: BoxDecoration( + color: appTheme.blueA400, + borderRadius: BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.center, + child: Text( + 'Добавить шаг', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/tags_widget.dart b/lib/features/recipes/presentation/widgets/tags_widget.dart index e69de29b..53ef22bf 100644 --- a/lib/features/recipes/presentation/widgets/tags_widget.dart +++ b/lib/features/recipes/presentation/widgets/tags_widget.dart @@ -0,0 +1,82 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:flutter/material.dart'; + +class TagsWidget extends StatefulWidget { + const TagsWidget({super.key}); + + @override + State createState() => _TagsWidgetState(); +} + +class _TagsWidgetState extends State { + final tagController = TextEditingController(); + final List tags = []; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Теги'), + SizedBox(height: 4), + CustomTextFormField( + hintText: 'Например: без майонеза', + controller: tagController, + suffix: IconButton( + onPressed: () { + if (tagController.text.isNotEmpty) { + setState(() { + tags.add(tagController.text); + tagController.clear(); + }); + } + }, + icon: Icon( + Icons.done, + color: appTheme.blueA400, + ), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < tags.length; i++) ...[ + InkWell( + onTap: () { + setState(() { + tags.remove(tags[i]); + }); + }, + borderRadius: BorderRadius.circular(16), + child: Ink( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Colors.grey.shade200, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tags[i], + ), + const SizedBox(width: 4), + Icon( + Icons.delete_outline_rounded, + color: Colors.grey.shade600, + size: 20, + ), + ], + ), + ), + ), + ], + ], + ), + ], + ); + } +} diff --git a/lib/features/recipes/presentation/widgets/time_of_cook_widget.dart b/lib/features/recipes/presentation/widgets/time_of_cook_widget.dart new file mode 100644 index 00000000..f329f791 --- /dev/null +++ b/lib/features/recipes/presentation/widgets/time_of_cook_widget.dart @@ -0,0 +1,151 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/recipes/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class TimeOfCookWidget extends StatefulWidget { + const TimeOfCookWidget({super.key}); + + @override + State createState() => _TimeOfCookWidgetState(); +} + +class _TimeOfCookWidgetState extends State { + late final CreateRecipeCubit createRecipeCubit; + + @override + void initState() { + super.initState(); + createRecipeCubit = context.read(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text('Общее время приготовления'), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text( + 'Общее время приготовления - это период времени от начала приготовления блюда до подачи его на стол.', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Color(0xFF323F4B), + height: 1.2, + ), + ), + ), + ); + }, + child: Icon( + Icons.help_outline_rounded, + color: appTheme.blueA400, + size: 20, + ), + ), + ], + ), + SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: CustomTextFormField( + hintText: 'Часов', + onChanged: (value) { + if (createRecipeCubit.state.entity.totalCookTime == null) { + createRecipeCubit.replaceTotalTime('${value.padLeft(2, '0')}:00'); + } else { + createRecipeCubit.replaceTotalTime('${value.padLeft(2, '0')}:${createRecipeCubit.state.entity.totalCookTime!.substring(3, 5)}'); + } + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: CustomTextFormField( + hintText: 'Минут', + onChanged: (value) { + if (createRecipeCubit.state.entity.totalCookTime == null) { + createRecipeCubit.replaceTotalTime('00:${value.padLeft(2, '0')}'); + } else { + createRecipeCubit.replaceTotalTime('${createRecipeCubit.state.entity.totalCookTime!.substring(0, 2)}:${value.padLeft(2, '0')}'); + } + }, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text('Активное время приготовления'), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text( + 'Активное время приготовления - это период времени, требующий активного участия в приготовлении блюда. Оно включает мытье и очистку продуктов, нарезку, шинковку, взбивание, перемешивание, размешивание, перекладывание и т.п.', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Color(0xFF323F4B), + height: 1.2, + ), + ), + ), + ); + }, + child: Icon( + Icons.help_outline_rounded, + color: appTheme.blueA400, + size: 20, + ), + ), + ], + ), + SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: CustomTextFormField( + hintText: 'Часов', + onChanged: (value) { + if (createRecipeCubit.state.entity.activeCookTime == null) { + createRecipeCubit.replaceActiveTime('${value.padLeft(2, '0')}:00'); + } else { + createRecipeCubit.replaceActiveTime('${value.padLeft(2, '0')}:${createRecipeCubit.state.entity.activeCookTime!.substring(3, 5)}'); + } + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: CustomTextFormField( + hintText: 'Минут', + onChanged: (value) { + if (createRecipeCubit.state.entity.activeCookTime == null) { + createRecipeCubit.replaceActiveTime('00:${value.padLeft(2, '0')}'); + } else { + createRecipeCubit.replaceActiveTime('${createRecipeCubit.state.entity.activeCookTime!.substring(0, 2)}:${value.padLeft(2, '0')}'); + } + }, + ), + ), + ], + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 341123b1..9a4a500b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: go_router: ^14.6.2 keyboard_dismisser: ^3.0.0 retrofit: ^4.4.1 - get_it: ^8.0.2 + get_it: ^8.0.3 dio: ^5.7.0 dart_pusher_channels: ^1.2.3 photo_view: ^0.15.0 @@ -39,11 +39,11 @@ dependencies: badges: ^3.1.2 google_fonts: ^6.2.1 location: ^7.0.1 - syncfusion_flutter_charts: ^27.2.5 - syncfusion_flutter_gauges: ^27.2.5 - table_calendar: ^3.1.2 + syncfusion_flutter_charts: ^28.1.33 + syncfusion_flutter_gauges: ^28.1.33 + table_calendar: ^3.1.3 flutter_keyboard_visibility: ^6.0.0 - share_plus: ^10.1.2 + share_plus: ^10.1.3 flutter_html: ^3.0.0-beta.2 file_picker: git: @@ -64,7 +64,7 @@ dependencies: http: ^1.2.2 dev_dependencies: - build_runner: ^2.4.13 + build_runner: ^2.4.14 freezed: ^2.5.7 json_serializable: ^6.9.0 retrofit_generator: ^9.1.5