diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 3ab485dc..196d2aa1 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -3,8 +3,8 @@ name: Eatvio App Android Test on: [ pull_request ] env: - flutter_version: "3.24.2" - java_version: "17.x" + flutter_version: "3.24.4" + java_version: "21.x" flutter_channel: "stable" jobs: @@ -24,8 +24,7 @@ jobs: - name: Install Android SDK run: | apt-get update - apt-get install -y openjdk-11-jdk wget - wget https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip -O commandlinetools.zip + https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip -O commandlinetools.zip unzip commandlinetools.zip mkdir -p $HOME/Android/Sdk/cmdline-tools/latest mv cmdline-tools/* $HOME/Android/Sdk/cmdline-tools/latest/ diff --git a/android/app/build.gradle b/android/app/build.gradle index 8a009c79..e5218dce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,15 +7,15 @@ plugins { android { namespace = "com.eatvioapp.app" compileSdk = flutter.compileSdkVersion - ndkVersion = "25.1.8937393" + ndkVersion = "28.0.12433566" compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = 17 + jvmTarget = 21 } defaultConfig { diff --git a/assets/no_one_image_was_added.svg b/assets/no_one_image_was_added.svg new file mode 100644 index 00000000..dc20c61c --- /dev/null +++ b/assets/no_one_image_was_added.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/features/diary/models/diary_day_model.dart b/lib/features/diary/models/diary_day_model.dart index 773920dc..e4d8a26f 100644 --- a/lib/features/diary/models/diary_day_model.dart +++ b/lib/features/diary/models/diary_day_model.dart @@ -47,10 +47,10 @@ class MealModel with _$MealModel { @freezed class DiaryPhotoModel with _$DiaryPhotoModel { const factory DiaryPhotoModel({ - @JsonKey(name: "id") required String id, + @JsonKey(name: "id") required int id, @JsonKey(name: "original_name") required String originalName, @JsonKey(name: "path") required String path, - @JsonKey(name: "size") required String size, + @JsonKey(name: "size") required int size, }) = _DiaryPhotoModel; factory DiaryPhotoModel.fromJson(Map json) => _$DiaryPhotoModelFromJson(json); @@ -78,3 +78,14 @@ class TypeOfDiaryModel with _$TypeOfDiaryModel { factory TypeOfDiaryModel.fromJson(Map json) => _$TypeOfDiaryModelFromJson(json); } + +@freezed +class ProductInMeal with _$ProductInMeal { + const factory ProductInMeal({ + @JsonKey(name: 'product_name') required String productName, + @JsonKey(name: 'portion') required String portion, + @JsonKey(name: 'quantity') required int quantity, + }) = _ProductInMeal; + + factory ProductInMeal.fromJson(Map json) => _$ProductInMealFromJson(json); +} diff --git a/lib/features/diary/models/diary_day_model.freezed.dart b/lib/features/diary/models/diary_day_model.freezed.dart index dc780145..72b0d70b 100644 --- a/lib/features/diary/models/diary_day_model.freezed.dart +++ b/lib/features/diary/models/diary_day_model.freezed.dart @@ -850,13 +850,13 @@ DiaryPhotoModel _$DiaryPhotoModelFromJson(Map json) { /// @nodoc mixin _$DiaryPhotoModel { @JsonKey(name: "id") - String get id => throw _privateConstructorUsedError; + int get id => throw _privateConstructorUsedError; @JsonKey(name: "original_name") String get originalName => throw _privateConstructorUsedError; @JsonKey(name: "path") String get path => throw _privateConstructorUsedError; @JsonKey(name: "size") - String get size => throw _privateConstructorUsedError; + int get size => throw _privateConstructorUsedError; /// Serializes this DiaryPhotoModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -875,10 +875,10 @@ abstract class $DiaryPhotoModelCopyWith<$Res> { _$DiaryPhotoModelCopyWithImpl<$Res, DiaryPhotoModel>; @useResult $Res call( - {@JsonKey(name: "id") String id, + {@JsonKey(name: "id") int id, @JsonKey(name: "original_name") String originalName, @JsonKey(name: "path") String path, - @JsonKey(name: "size") String size}); + @JsonKey(name: "size") int size}); } /// @nodoc @@ -905,7 +905,7 @@ class _$DiaryPhotoModelCopyWithImpl<$Res, $Val extends DiaryPhotoModel> id: null == id ? _value.id : id // ignore: cast_nullable_to_non_nullable - as String, + as int, originalName: null == originalName ? _value.originalName : originalName // ignore: cast_nullable_to_non_nullable @@ -917,7 +917,7 @@ class _$DiaryPhotoModelCopyWithImpl<$Res, $Val extends DiaryPhotoModel> size: null == size ? _value.size : size // ignore: cast_nullable_to_non_nullable - as String, + as int, ) as $Val); } } @@ -931,10 +931,10 @@ abstract class _$$DiaryPhotoModelImplCopyWith<$Res> @override @useResult $Res call( - {@JsonKey(name: "id") String id, + {@JsonKey(name: "id") int id, @JsonKey(name: "original_name") String originalName, @JsonKey(name: "path") String path, - @JsonKey(name: "size") String size}); + @JsonKey(name: "size") int size}); } /// @nodoc @@ -959,7 +959,7 @@ class __$$DiaryPhotoModelImplCopyWithImpl<$Res> id: null == id ? _value.id : id // ignore: cast_nullable_to_non_nullable - as String, + as int, originalName: null == originalName ? _value.originalName : originalName // ignore: cast_nullable_to_non_nullable @@ -971,7 +971,7 @@ class __$$DiaryPhotoModelImplCopyWithImpl<$Res> size: null == size ? _value.size : size // ignore: cast_nullable_to_non_nullable - as String, + as int, )); } } @@ -990,7 +990,7 @@ class _$DiaryPhotoModelImpl implements _DiaryPhotoModel { @override @JsonKey(name: "id") - final String id; + final int id; @override @JsonKey(name: "original_name") final String originalName; @@ -999,7 +999,7 @@ class _$DiaryPhotoModelImpl implements _DiaryPhotoModel { final String path; @override @JsonKey(name: "size") - final String size; + final int size; @override String toString() { @@ -1041,18 +1041,17 @@ class _$DiaryPhotoModelImpl implements _DiaryPhotoModel { abstract class _DiaryPhotoModel implements DiaryPhotoModel { const factory _DiaryPhotoModel( - {@JsonKey(name: "id") required final String id, - @JsonKey(name: "original_name") required final String originalName, - @JsonKey(name: "path") required final String path, - @JsonKey(name: "size") required final String size}) = - _$DiaryPhotoModelImpl; + {@JsonKey(name: "id") required final int id, + @JsonKey(name: "original_name") required final String originalName, + @JsonKey(name: "path") required final String path, + @JsonKey(name: "size") required final int size}) = _$DiaryPhotoModelImpl; factory _DiaryPhotoModel.fromJson(Map json) = _$DiaryPhotoModelImpl.fromJson; @override @JsonKey(name: "id") - String get id; + int get id; @override @JsonKey(name: "original_name") String get originalName; @@ -1061,7 +1060,7 @@ abstract class _DiaryPhotoModel implements DiaryPhotoModel { String get path; @override @JsonKey(name: "size") - String get size; + int get size; /// Create a copy of DiaryPhotoModel /// with the given fields replaced by the non-null parameter values. @@ -1502,3 +1501,208 @@ abstract class _TypeOfDiaryModel implements TypeOfDiaryModel { _$$TypeOfDiaryModelImplCopyWith<_$TypeOfDiaryModelImpl> get copyWith => throw _privateConstructorUsedError; } + +ProductInMeal _$ProductInMealFromJson(Map json) { + return _ProductInMeal.fromJson(json); +} + +/// @nodoc +mixin _$ProductInMeal { + @JsonKey(name: 'product_name') + String get productName => throw _privateConstructorUsedError; + @JsonKey(name: 'portion') + String get portion => throw _privateConstructorUsedError; + @JsonKey(name: 'quantity') + int get quantity => throw _privateConstructorUsedError; + + /// Serializes this ProductInMeal to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ProductInMeal + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProductInMealCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProductInMealCopyWith<$Res> { + factory $ProductInMealCopyWith( + ProductInMeal value, $Res Function(ProductInMeal) then) = + _$ProductInMealCopyWithImpl<$Res, ProductInMeal>; + @useResult + $Res call( + {@JsonKey(name: 'product_name') String productName, + @JsonKey(name: 'portion') String portion, + @JsonKey(name: 'quantity') int quantity}); +} + +/// @nodoc +class _$ProductInMealCopyWithImpl<$Res, $Val extends ProductInMeal> + implements $ProductInMealCopyWith<$Res> { + _$ProductInMealCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProductInMeal + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? productName = null, + Object? portion = null, + Object? quantity = null, + }) { + return _then(_value.copyWith( + productName: null == productName + ? _value.productName + : productName // ignore: cast_nullable_to_non_nullable + as String, + portion: null == portion + ? _value.portion + : portion // ignore: cast_nullable_to_non_nullable + as String, + quantity: null == quantity + ? _value.quantity + : quantity // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ProductInMealImplCopyWith<$Res> + implements $ProductInMealCopyWith<$Res> { + factory _$$ProductInMealImplCopyWith( + _$ProductInMealImpl value, $Res Function(_$ProductInMealImpl) then) = + __$$ProductInMealImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'product_name') String productName, + @JsonKey(name: 'portion') String portion, + @JsonKey(name: 'quantity') int quantity}); +} + +/// @nodoc +class __$$ProductInMealImplCopyWithImpl<$Res> + extends _$ProductInMealCopyWithImpl<$Res, _$ProductInMealImpl> + implements _$$ProductInMealImplCopyWith<$Res> { + __$$ProductInMealImplCopyWithImpl( + _$ProductInMealImpl _value, $Res Function(_$ProductInMealImpl) _then) + : super(_value, _then); + + /// Create a copy of ProductInMeal + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? productName = null, + Object? portion = null, + Object? quantity = null, + }) { + return _then(_$ProductInMealImpl( + productName: null == productName + ? _value.productName + : productName // ignore: cast_nullable_to_non_nullable + as String, + portion: null == portion + ? _value.portion + : portion // ignore: cast_nullable_to_non_nullable + as String, + quantity: null == quantity + ? _value.quantity + : quantity // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProductInMealImpl implements _ProductInMeal { + const _$ProductInMealImpl( + {@JsonKey(name: 'product_name') required this.productName, + @JsonKey(name: 'portion') required this.portion, + @JsonKey(name: 'quantity') required this.quantity}); + + factory _$ProductInMealImpl.fromJson(Map json) => + _$$ProductInMealImplFromJson(json); + + @override + @JsonKey(name: 'product_name') + final String productName; + @override + @JsonKey(name: 'portion') + final String portion; + @override + @JsonKey(name: 'quantity') + final int quantity; + + @override + String toString() { + return 'ProductInMeal(productName: $productName, portion: $portion, quantity: $quantity)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProductInMealImpl && + (identical(other.productName, productName) || + other.productName == productName) && + (identical(other.portion, portion) || other.portion == portion) && + (identical(other.quantity, quantity) || + other.quantity == quantity)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, productName, portion, quantity); + + /// Create a copy of ProductInMeal + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProductInMealImplCopyWith<_$ProductInMealImpl> get copyWith => + __$$ProductInMealImplCopyWithImpl<_$ProductInMealImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ProductInMealImplToJson( + this, + ); + } +} + +abstract class _ProductInMeal implements ProductInMeal { + const factory _ProductInMeal( + {@JsonKey(name: 'product_name') required final String productName, + @JsonKey(name: 'portion') required final String portion, + @JsonKey(name: 'quantity') required final int quantity}) = + _$ProductInMealImpl; + + factory _ProductInMeal.fromJson(Map json) = + _$ProductInMealImpl.fromJson; + + @override + @JsonKey(name: 'product_name') + String get productName; + @override + @JsonKey(name: 'portion') + String get portion; + @override + @JsonKey(name: 'quantity') + int get quantity; + + /// Create a copy of ProductInMeal + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProductInMealImplCopyWith<_$ProductInMealImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/diary/models/diary_day_model.g.dart b/lib/features/diary/models/diary_day_model.g.dart index 05d36efa..93ece876 100644 --- a/lib/features/diary/models/diary_day_model.g.dart +++ b/lib/features/diary/models/diary_day_model.g.dart @@ -74,10 +74,10 @@ Map _$$MealModelImplToJson(_$MealModelImpl instance) => _$DiaryPhotoModelImpl _$$DiaryPhotoModelImplFromJson( Map json) => _$DiaryPhotoModelImpl( - id: json['id'] as String, + id: (json['id'] as num).toInt(), originalName: json['original_name'] as String, path: json['path'] as String, - size: json['size'] as String, + size: (json['size'] as num).toInt(), ); Map _$$DiaryPhotoModelImplToJson( @@ -120,3 +120,17 @@ Map _$$TypeOfDiaryModelImplToJson( 'has_chosen_diet': instance.hasChosenDiet, 'has_chosen_plan': instance.hasChosenPlan, }; + +_$ProductInMealImpl _$$ProductInMealImplFromJson(Map json) => + _$ProductInMealImpl( + productName: json['product_name'] as String, + portion: json['portion'] as String, + quantity: (json['quantity'] as num).toInt(), + ); + +Map _$$ProductInMealImplToJson(_$ProductInMealImpl instance) => + { + 'product_name': instance.productName, + 'portion': instance.portion, + 'quantity': instance.quantity, + }; diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index d5b4fede..d7f31da6 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; import 'package:eatvio_app/features/diary/repositories/diary_repository.dart'; import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; @@ -41,7 +44,69 @@ class DiaryCubit extends Cubit { await getDiaryDay(date, false); } + updateProductInMeal({ + required int diaryId, + required int mealId, + required int portionId, + required String productName, + required String portion, + required int quantity, + required String date, + }) async { + await diaryRepository.updateProductInMeal( + diaryId: diaryId, + mealId: mealId, + portionId: portionId, + productName: productName, + portion: portion, + quantity: quantity, + ); + await getDiaryDay(date, false); + } + + deleteProductFromMeal({ + required int diaryId, + required int mealId, + required int portionId, + required String date, + }) async { + await diaryRepository.deleteProductFromMeal( + diaryId: diaryId, + mealId: mealId, + portionId: portionId, + ); + await getDiaryDay(date, false); + } + Future> getGlossaryListByQuery(String query) async { return await glossaryRepository.getGlossaryListByQuery(query); } + + uploadPhotos({ + required List files, + required int diaryId, + required int mealId, + required String date, + }) async { + await diaryRepository.uploadPhotos( + files: files, + diaryId: diaryId, + mealId: mealId, + ); + await getDiaryDay(date, false); + } + + deletePhoto({ + required DiaryPhotoModel photo, + required int diaryId, + required int mealId, + required String date, + }) async { + await diaryRepository.deletePhoto( + photo: photo, + diaryId: diaryId, + mealId: mealId, + ); + await getDiaryDay(date, false); + } } diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index 32b70516..bb9e530a 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; @@ -5,9 +8,12 @@ import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; import 'package:eatvio_app/locator_service.dart'; import 'package:eatvio_app/theme/theme_helper.dart'; import 'package:eatvio_app/widgets/custom_text_form_field.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:flutter_typeahead/flutter_typeahead.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:rive/rive.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -27,6 +33,7 @@ class _DiaryScreenState extends State { GlossaryModel? selectedGlossary; final productNameController = TextEditingController(); final quantityController = TextEditingController(); + int currentSelectedPhotoIndex = 0; @override Widget build(BuildContext context) { @@ -85,12 +92,14 @@ class _DiaryScreenState extends State { ); } - _products(List portions) { + _products(List portions, DiaryDayModel diaryDay) { return Column( mainAxisSize: MainAxisSize.min, children: [ for (int i = 0; i < portions.length; i++) ...[ _product( + diaryDay, + portions[i].id, portions[i].productName, portions[i].quantity, portions[i].portion, @@ -101,10 +110,16 @@ class _DiaryScreenState extends State { ); } - _product(String product, int quantity, String portion) { + _product(DiaryDayModel diaryDay, int id, String product, int quantity, String portion) { portion == 'gr' ? portion = 'г' : portion = 'шт'; return ListTile( - onTap: () {}, + onTap: () { + setState(() { + productNameController.text = product; + quantityController.text = quantity.toString(); + }); + _modalBottomSheet(diaryDay, id); + }, trailing: Icon(Icons.more_vert_rounded), title: Text(product), subtitle: Text('$quantity $portion'), @@ -157,15 +172,184 @@ class _DiaryScreenState extends State { const SizedBox(height: 12), _divider(context), const SizedBox(height: 12), - _products(diaryDay.meals[currentSelectedMealNumber - 1].portions), + _products(diaryDay.meals[currentSelectedMealNumber - 1].portions, diaryDay), const SizedBox(height: 16), _addProductButton(diaryDay), + const SizedBox(height: 24), + if (diaryDay.meals[currentSelectedMealNumber - 1].photos.isEmpty) + GestureDetector( + onTap: () => _showImagePicker(diaryDay), + child: SvgPicture.asset( + width: MediaQuery.of(context).size.width - 32, + 'assets/no_one_image_was_added.svg', + ), + ), + if (diaryDay.meals[currentSelectedMealNumber - 1].photos.isNotEmpty) + _photoGallery( + diaryDay.meals[currentSelectedMealNumber - 1].photos, + diaryDay, + ), ], ), ), ); } + _showImagePicker(DiaryDayModel diaryDay) { + showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + final picker = ImagePicker(); + XFile? image = await picker.pickImage(source: ImageSource.camera); + if (image != null) + diaryCubit.uploadPhotos( + files: [File(image.path)], + diaryId: diaryDay.id, + mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, + date: _selectedDay.toString(), + ); + Navigator.of(context).pop(); + }, + child: const Row( + children: const [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () async { + final picker = ImagePicker(); + List? images = await picker.pickMultiImage(); + if (images.isNotEmpty) + diaryCubit.uploadPhotos( + files: images.map((f) => File(f.path)).toList(), + diaryId: diaryDay.id, + mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, + date: _selectedDay.toString(), + ); + 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('Отмена'), + ), + ), + ); + } + + _photoGallery(List photos, DiaryDayModel diaryDay) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.topRight, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: baseUrl + '/storage/' + photos[currentSelectedPhotoIndex].path, + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: Colors.white, + ), + onPressed: () { + setState(() { + if (currentSelectedPhotoIndex == photos.length - 1 && currentSelectedPhotoIndex != 0) { + currentSelectedPhotoIndex = photos.length - 2; + } + }); + diaryCubit.deletePhoto( + photo: photos[currentSelectedPhotoIndex], + diaryId: diaryDay.id, + mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, + date: _selectedDay.toString(), + ); + }, + icon: Icon( + Icons.close_rounded, + color: Colors.grey, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + SingleChildScrollView( + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (int i = 0; i < photos.length + 1; i++) ...[ + if (i == photos.length) + Padding( + padding: const EdgeInsets.only(left: 8), + child: InkWell( + onTap: () => _showImagePicker(diaryDay), + borderRadius: BorderRadius.circular(8), + child: Ink( + height: 52, + width: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color(0xFFF1F2F6), + ), + child: Icon( + Icons.add_rounded, + color: appTheme.blueA400, + ), + ), + ), + ), + if (i < photos.length) + GestureDetector( + onTap: () { + setState(() { + currentSelectedPhotoIndex = i; + }); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 52, + width: 52, + child: CachedNetworkImage( + imageUrl: baseUrl + '/storage/' + photos[i].path, + fit: BoxFit.fitWidth, + ), + ), + ), + ), + if (i < photos.length - 1) const SizedBox(width: 8), + ], + ], + ), + ), + ], + ); + } + _mealButton(int mealNumber) { bool isActive = currentSelectedMealNumber == mealNumber; @@ -285,117 +469,152 @@ class _DiaryScreenState extends State { ); } - _addProductButton(DiaryDayModel diaryDay) { - return InkWell( - onTap: () { - setState(() { - selectedGlossary = null; - productNameController.clear(); - quantityController.clear(); - }); - showModalBottomSheet( - useRootNavigator: true, - context: context, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - isScrollControlled: true, - builder: (context) { - return StatefulBuilder( - builder: (BuildContext context, void Function(void Function()) setState) { - 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), - TypeAheadField( - controller: productNameController, - itemBuilder: (context, glossary) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - glossary.name, - style: TextStyle( - height: 1, - fontSize: 16, - ), - ), - ); - }, - itemSeparatorBuilder: (context, index) => _divider(context), - onSelected: (newSelectedGlossary) { - setState(() { - selectedGlossary = newSelectedGlossary; - productNameController.text = selectedGlossary!.name; - }); - }, - suggestionsCallback: (query) async { - if (query.length < 1) return []; - return await diaryCubit.getGlossaryListByQuery(query); - }, - builder: (context, controller, focusNode) { - return CustomTextFormField( - controller: controller, - focusNode: focusNode, - autofocus: true, - hintText: 'Введите название продукта/блюда', - ); - }, - emptyBuilder: (context) => const SizedBox.shrink(), - errorBuilder: (context, object) => Padding( - padding: const EdgeInsets.all(8.0), - child: Text('Нет подходящих вариантов'), - ), - ), - if (selectedGlossary != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: CustomTextFormField( - controller: quantityController, - keyboardType: TextInputType.number, - hintText: selectedGlossary!.grInPortion == 0 ? '100' : '1', - suffixText: selectedGlossary != null - ? selectedGlossary!.grInPortion == 0 - ? 'гр' - : 'шт' - : null, + _modalBottomSheet(DiaryDayModel diaryDay, [int? portionId]) { + return showModalBottomSheet( + useRootNavigator: true, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + isScrollControlled: true, + builder: (context) { + return StatefulBuilder( + builder: (BuildContext context, void Function(void Function()) setState) { + 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), + TypeAheadField( + controller: productNameController, + itemBuilder: (context, glossary) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + glossary.name, + style: TextStyle( + height: 1, + fontSize: 16, ), ), - const SizedBox(height: 16), + ); + }, + itemSeparatorBuilder: (context, index) => _divider(context), + onSelected: (newSelectedGlossary) { + setState(() { + selectedGlossary = newSelectedGlossary; + productNameController.text = selectedGlossary!.name; + }); + }, + suggestionsCallback: (query) async { + if (query.length < 1) return []; + final suggestionsList = await diaryCubit.getGlossaryListByQuery(query); + if (suggestionsList.length == 1) { + setState(() { + selectedGlossary = suggestionsList.first; + }); + } + return suggestionsList; + }, + builder: (context, controller, focusNode) { + return CustomTextFormField( + controller: controller, + autofocus: true, + focusNode: focusNode, + hintText: 'Введите название продукта/блюда', + ); + }, + emptyBuilder: (context) => const SizedBox.shrink(), + errorBuilder: (context, object) => Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Нет подходящих вариантов'), + ), + ), + if (selectedGlossary != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: CustomTextFormField( + controller: quantityController, + keyboardType: TextInputType.number, + hintText: selectedGlossary!.grInPortion == 0 ? '100' : '1', + autofocus: true, + suffixText: selectedGlossary!.grInPortion == 0 ? 'гр' : 'шт', + ), + ), + const SizedBox(height: 16), + Row( + children: [ + if (portionId != null) + InkWell( + onTap: () { + diaryCubit.deleteProductFromMeal( + diaryId: diaryDay.id, + mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, + portionId: portionId, + date: _selectedDay.toString(), + ); + Navigator.of(context).pop(); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: 52, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Color(0xFFF6F6F6), + ), + child: Icon( + Icons.delete_outline_rounded, + color: Colors.red, + ), + ), + ), + if (portionId != null) const SizedBox(width: 8), InkWell( onTap: () { - diaryCubit.addProductToMeal( - diaryId: diaryDay.id, - mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, - productName: productNameController.text, - portion: selectedGlossary!.grInPortion == 0 ? 'gr' : 'pcs', - quantity: int.parse(quantityController.text), - date: _selectedDay.toString(), - ); + if (portionId == null) { + diaryCubit.addProductToMeal( + diaryId: diaryDay.id, + mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, + productName: productNameController.text, + portion: selectedGlossary!.grInPortion == 0 ? 'gr' : 'pcs', + quantity: int.parse(quantityController.text), + date: _selectedDay.toString(), + ); + } else { + diaryCubit.updateProductInMeal( + diaryId: diaryDay.id, + mealId: diaryDay.meals[currentSelectedMealNumber - 1].id, + portionId: portionId, + productName: productNameController.text, + portion: selectedGlossary!.grInPortion == 0 ? 'gr' : 'pcs', + quantity: int.parse(quantityController.text), + date: _selectedDay.toString(), + ); + } Navigator.of(context).pop(); }, borderRadius: BorderRadius.circular(20), child: Ink( + width: MediaQuery.of(context).size.width - (portionId == null ? 32 : 92), + height: 52, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: appTheme.blueA400, ), - child: Container( - width: MediaQuery.of(context).size.width - 32, - padding: const EdgeInsets.all(16), - alignment: Alignment.center, + child: Center( child: Text( - 'Добавить', + portionId == null ? 'Добавить' : 'Сохранить', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -407,12 +626,25 @@ class _DiaryScreenState extends State { ), ], ), - ); - }, + ], + ), ); }, ); }, + ); + } + + _addProductButton(DiaryDayModel diaryDay) { + return InkWell( + onTap: () { + setState(() { + selectedGlossary = null; + productNameController.clear(); + quantityController.clear(); + }); + _modalBottomSheet(diaryDay); + }, borderRadius: BorderRadius.circular(20), child: Ink( padding: EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart index dde8a702..b9b8243c 100644 --- a/lib/features/diary/repositories/diary_repository.dart +++ b/lib/features/diary/repositories/diary_repository.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; import 'package:eatvio_app/features/diary/services/diary_service.dart'; @@ -34,4 +35,71 @@ class DiaryRepository { quantity: quantity, ); } + + Future updateProductInMeal({ + required int diaryId, + required int mealId, + required int portionId, + required String productName, + required String portion, + required int quantity, + }) async { + return await diaryService.updateProductInMeal( + diaryId: diaryId, + mealId: mealId, + portionId: portionId, + productInMeal: ProductInMeal( + productName: productName, + portion: portion, + quantity: quantity, + ), + ); + } + + Future deleteProductFromMeal({ + required int diaryId, + required int mealId, + required int portionId, + }) async { + return await diaryService.deleteProductFromMeal( + diaryId: diaryId, + mealId: mealId, + portionId: portionId, + ); + } + + Future uploadPhotos({ + required int diaryId, + required int mealId, + required List files, + }) async { + try { + for (int i = 0; i < files.length; i++) { + await diaryService.uploadPhotoOfMeal( + diaryId: diaryId, + mealId: mealId, + photo: files[i], + ); + } + } catch (e) { + rethrow; + } + } + + Future deletePhoto({ + required int diaryId, + required int mealId, + required DiaryPhotoModel photo, + }) async { + try { + await diaryService.deletePhotoOfMeal( + diaryId: diaryId, + mealId: mealId, + id: photo.id, + path: photo.path, + ); + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/diary/services/diary_service.dart b/lib/features/diary/services/diary_service.dart index cdce2dd4..16990b1b 100644 --- a/lib/features/diary/services/diary_service.dart +++ b/lib/features/diary/services/diary_service.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; import 'package:eatvio_app/features/diary/models/diary_index_model.dart'; @@ -19,6 +20,22 @@ abstract class DiaryService { @Path('diary_id') required int index, }); + @POST('/api/diary/{diary_id}/meal/{meal_id}/photo/upload') + @MultiPart() + Future uploadPhotoOfMeal({ + @Path('diary_id') required int diaryId, + @Path('meal_id') required int mealId, + @Part(name: 'photo') required File photo, + }); + + @DELETE('/api/diary/{diary_id}/meal/{meal_id}/photo/delete') + Future deletePhotoOfMeal({ + @Path('diary_id') required int diaryId, + @Path('meal_id') required int mealId, + @Query('id') required int id, + @Query('path') required String path, + }); + @POST('/api/diary/{diary_id}/meal/{meal_id}/portions/create') Future addProductToMeal({ @Path('diary_id') required int diaryId, @@ -27,4 +44,19 @@ abstract class DiaryService { @Part(name: 'portion') required String portion, @Part(name: 'quantity') required int quantity, }); + + @PUT('/api/diary/{diary_id}/meal/{meal_id}/portions/{portion_id}/update') + Future updateProductInMeal({ + @Path('diary_id') required int diaryId, + @Path('meal_id') required int mealId, + @Path('portion_id') required int portionId, + @Body() required ProductInMeal productInMeal, + }); + + @DELETE('/api/diary/{diary_id}/meal/{meal_id}/portions/{portion_id}/delete') + Future deleteProductFromMeal({ + @Path('diary_id') required int diaryId, + @Path('meal_id') required int mealId, + @Path('portion_id') required int portionId, + }); } diff --git a/lib/features/diary/services/diary_service.g.dart b/lib/features/diary/services/diary_service.g.dart index b145757c..74de16af 100644 --- a/lib/features/diary/services/diary_service.g.dart +++ b/lib/features/diary/services/diary_service.g.dart @@ -87,6 +87,76 @@ class _DiaryService implements DiaryService { return _value; } + @override + Future uploadPhotoOfMeal({ + required int diaryId, + required int mealId, + required File photo, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = FormData(); + _data.files.add(MapEntry( + 'photo', + MultipartFile.fromFileSync( + photo.path, + filename: photo.path.split(Platform.pathSeparator).last, + ), + )); + final _options = _setStreamType(Options( + method: 'POST', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/api/diary/${diaryId}/meal/${mealId}/photo/upload', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + await _dio.fetch(_options); + } + + @override + Future deletePhotoOfMeal({ + required int diaryId, + required int mealId, + required int id, + required String path, + }) async { + final _extra = {}; + final queryParameters = { + r'id': id, + r'path': path, + }; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType(Options( + method: 'DELETE', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/api/diary/${diaryId}/meal/${mealId}/photo/delete', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + await _dio.fetch(_options); + } + @override Future addProductToMeal({ required int diaryId, @@ -130,6 +200,65 @@ class _DiaryService implements DiaryService { await _dio.fetch(_options); } + @override + Future updateProductInMeal({ + required int diaryId, + required int mealId, + required int portionId, + required ProductInMeal productInMeal, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = productInMeal; + final _options = _setStreamType(Options( + method: 'PUT', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/api/diary/${diaryId}/meal/${mealId}/portions/${portionId}/update', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + await _dio.fetch(_options); + } + + @override + Future deleteProductFromMeal({ + required int diaryId, + required int mealId, + required int portionId, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType(Options( + method: 'DELETE', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/api/diary/${diaryId}/meal/${mealId}/portions/${portionId}/delete', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + await _dio.fetch(_options); + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index b94f2f71..380d37c4 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -112,7 +112,7 @@ class _ProfileScreenState extends State { ), ), _subscriptionsAndSubscribers(state), - _badges(state), + if(state.profile.badges.isNotEmpty) _badges(state), if (widget.author == null) const SizedBox(height: 16), _socialLinks(state), if (widget.author == null) const SizedBox(height: 16), @@ -201,42 +201,39 @@ class _ProfileScreenState extends State { } _newBlogPostButton() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Material( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - onTap: () { - GoRouter.of(context).pushNamed('blogPostCreateScreen'); - }, - borderRadius: BorderRadius.circular(8), - child: Ink( - padding: EdgeInsets.all(12), - decoration: BoxDecoration( - color: Color(0xFFF6FAFF), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_circle_outline_rounded, + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + onTap: () { + GoRouter.of(context).pushNamed('blogPostCreateScreen'); + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Color(0xFFF6FAFF), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle_outline_rounded, + color: Color(0xFF2F80ED), + ), + const SizedBox(width: 12), + Text( + 'Новая запись', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, color: Color(0xFF2F80ED), ), - const SizedBox(width: 12), - Text( - 'Новая запись', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF2F80ED), - ), - ), - ], - ), + ), + ], ), ), ), @@ -672,7 +669,8 @@ class _ProfileScreenState extends State { state.profile.name, style: TextStyle( color: Colors.black, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, + fontSize: 16, height: 1, ), ), @@ -689,6 +687,8 @@ class _ProfileScreenState extends State { 'Дополнительно', style: TextStyle( color: Color(0xFF6D6F71), + fontSize: 14, + fontWeight: FontWeight.w500, ), ), ],