Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Username1548
2025-05-19 21:38:20 +07:00
53 changed files with 534 additions and 216 deletions

View File

@@ -30,7 +30,7 @@ class AuthCubit extends Cubit<AuthState> {
);
showSuccessToast('Успешный вход');
await TokenStorage.saveTokenInStorage(token);
AppRouter.navigatorKey.currentContext?.go('/my-profile-screen');
AppRouter.navigatorKey.currentContext?.go('/feed-screen');
return true;
} catch (e) {
log(e.toString());

View File

@@ -27,7 +27,7 @@ class BottomNavigationBarWidget extends StatefulWidget {
}
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
int currentBottomNavBarIndex = 0;
int currentBottomNavBarIndex = 1;
late final List<String> pages;
@override

View File

@@ -0,0 +1,26 @@
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'consultation_request_model.freezed.dart';
part 'consultation_request_model.g.dart';
@freezed
class ConsultationRequestModel with _$ConsultationRequestModel {
const factory ConsultationRequestModel({
@JsonKey(name: 'rating_place') required int ratingPlace,
@JsonKey(name: 'education_resource') required EducationModel educationModel,
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
}) = _ConsultationRequestModel;
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
}
@freezed
class CertPhotosModel with _$CertPhotosModel {
const factory CertPhotosModel({
required String url,
}) = _CertPhotosModel;
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:http_parser/http_parser.dart';
import 'package:dio/dio.dart';
@@ -33,7 +34,9 @@ class ConsultantRepository {
final fileBytes = await file.readAsBytes();
final multipartFile = MultipartFile.fromBytes(
fileBytes,
filename: file.path.split('/').last,
filename: file.path
.split('/')
.last,
contentType: MediaType('application', 'octet-stream'),
);
multipartFiles.add(multipartFile);
@@ -46,4 +49,12 @@ class ConsultantRepository {
rethrow;
}
}
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
try {
return await consultantService.getConsultantRequestStatus(slug: slug);
} catch (e) {
rethrow;
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:retrofit/retrofit.dart';
part 'consultant_service.g.dart';
@@ -18,4 +19,9 @@ abstract class ConsultantService {
Future<List<String>> uploadPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos,
});
@GET('/api/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getConsultantRequestStatus({
@Path() required String slug,
});
}

View File

@@ -55,6 +55,27 @@ enum TypeOfRecipe {
}
}
static TypeOfRecipe? fromApiType(String value) {
switch (value) {
case 'first_meals':
return TypeOfRecipe.firstMeals;
case 'second_meals':
return TypeOfRecipe.secondMeals;
case 'salads':
return TypeOfRecipe.salads;
case 'snacks':
return TypeOfRecipe.snacks;
case 'sweets':
return TypeOfRecipe.sweets;
case 'drinks':
return TypeOfRecipe.drinks;
case 'other':
return TypeOfRecipe.other;
default:
return null;
}
}
static TypeOfRecipe? fromString(String value) {
switch (value) {
case 'Первые блюда':
@@ -80,6 +101,7 @@ enum TypeOfRecipe {
@freezed
class RecipeCreateEntity with _$RecipeCreateEntity {
const RecipeCreateEntity._();
const factory RecipeCreateEntity({
String? title,
TypeOfRecipe? typeOfRecipe,
@@ -94,7 +116,7 @@ class RecipeCreateEntity with _$RecipeCreateEntity {
String? videoUrl,
required List<Ingredient> ingredients,
required List<StepEntity> steps,
required List<String> tags, // tags for search
required List<String> tags,
}) = _RecipeCreateEntity;
RecipeModel toModel() => RecipeModel(
@@ -118,6 +140,7 @@ class RecipeCreateEntity with _$RecipeCreateEntity {
@freezed
class StepEntity with _$StepEntity {
const StepEntity._();
const factory StepEntity({
String? description,
File? image,

View File

@@ -1,13 +1,17 @@
import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart';
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart' as i;
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/glossary/models/glossary_model.dart';
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
class CreateRecipeCubit extends Cubit<CreateRecipeState> {
final RecipeRepository recipeRepository;
@@ -17,6 +21,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
required this.glossaryRepository,
}) : super(
const CreateRecipeState(
loading: true,
entity: RecipeCreateEntity(
typeOfRecipe: TypeOfRecipe.firstMeals,
ingredients: [],
@@ -43,7 +48,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
emit(state.copyWith.entity(steps: steps));
}
addIngredient(Ingredient entity) {
addIngredient(i.Ingredient entity) {
emit(
state.copyWith.entity(
ingredients: [
@@ -55,20 +60,20 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
removeIngredient(int index) {
List<Ingredient> ingredients = [...state.entity.ingredients];
List<i.Ingredient> ingredients = [...state.entity.ingredients];
ingredients.removeAt(index);
emit(state.copyWith.entity(ingredients: ingredients));
}
void updateIngredient({required int index, required Ingredient ingredient}) {
void updateIngredient({required int index, required i.Ingredient ingredient}) {
final ingredients = [...state.entity.ingredients];
ingredients[index] = ingredient;
emit(state.copyWith.entity(ingredients: ingredients));
}
replaceIngredientPosition(int oldIndex, int newIndex) {
List<Ingredient> ingredients = [...state.entity.ingredients];
List<i.Ingredient> ingredients = [...state.entity.ingredients];
final item = ingredients.removeAt(oldIndex);
ingredients.insert(newIndex, item);
emit(state.copyWith.entity(ingredients: ingredients));
@@ -152,10 +157,10 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
emit(state.copyWith.entity(steps: steps));
}
Future<bool> publishRecipe() async {
Future<bool> publishRecipe([bool update = false, String? slug]) async {
try {
final model = state.entity.toModel();
await recipeRepository.publishRecipe(model);
await recipeRepository.publishRecipe(model, update, slug);
return true;
} catch (e, st) {
log(e.toString(), stackTrace: st);
@@ -163,6 +168,64 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
}
Future<File> downloadImageAsFile(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode == 200) {
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/${imageUrl.split('/').last}';
final file = File(filePath);
return await file.writeAsBytes(response.bodyBytes);
} else {
throw Exception('Не удалось загрузить изображение');
}
}
disableLoading() {
emit(state.copyWith(loading: false));
}
Future<void> recipeToEntity(Recipe recipe) async {
final file = await downloadImageAsFile('$baseUrl/storage/${recipe.image}');
final List<StepEntity> steps = await Future.wait(
recipe.steps!.map(
(e) async => StepEntity(
description: e.description,
image: await downloadImageAsFile('$baseUrl/storage/${e.image}'),
),
),
);
emit(
CreateRecipeState(
entity: RecipeCreateEntity(
title: recipe.title,
typeOfRecipe: TypeOfRecipe.fromApiType(recipe.type!),
countOfPortions: recipe.servings,
energyInOnePortion: recipe.calories,
totalCookHours: recipe.cookingTime ~/ 60,
totalCookMinutes: recipe.cookingTime % 60,
activeCookHours: recipe.activeCookingTime ~/ 60,
activeCookMinutes: recipe.activeCookingTime % 60,
description: recipe.description,
image: file,
videoUrl: recipe.videoUrl,
ingredients: recipe.ingredients!
.map((e) => i.Ingredient(
name: e.name,
unit: e.unit!,
portion: e.portion,
productId: e.productId,
))
.toList(),
steps: steps,
tags: recipe.tags.map((e) => (e.name)).toList(),
),
loading: false,
),
);
}
bool validateAll() {
final isTitleValid = validateTitle();
final isDescValid = validateDescription();
@@ -251,6 +314,15 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
}
Future<bool> deleteRecipe(String slug) async {
try {
await recipeRepository.deleteRecipe(slug);
return true;
} catch (e) {
return false;
}
}
Future<List<GlossaryModel>> getGlossaryListByQuery(String query) async {
return await glossaryRepository.getGlossaryListByQuery(query);
}

View File

@@ -7,6 +7,7 @@ part 'create_recipe_state.freezed.dart';
class CreateRecipeState with _$CreateRecipeState {
const factory CreateRecipeState({
required RecipeCreateEntity entity,
required bool loading,
String? titleValidationError,
String? descriptionValidationError,
String? cookingTimeValidationError,
@@ -15,5 +16,5 @@ class CreateRecipeState with _$CreateRecipeState {
String? energyPerServingValidationError,
String? imageValidationError,
}) = _CreateRecipeState;
}

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/core/widgets/success_published_recipe_bottom_sheet.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
@@ -8,13 +9,19 @@ import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recip
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class RecipeCreateScreen extends StatefulWidget {
const RecipeCreateScreen({super.key});
final Recipe? recipe;
const RecipeCreateScreen({
super.key,
this.recipe,
});
static const name = 'recipeCreateScreen';
static const path = '/recipe-create-screen';
@@ -24,85 +31,115 @@ class RecipeCreateScreen extends StatefulWidget {
}
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
createRecipeCubit = sl<CreateRecipeCubit>();
if (widget.recipe != null) {
createRecipeCubit.recipeToEntity(widget.recipe!);
} else {
createRecipeCubit.disableLoading();
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocProvider<CreateRecipeCubit>(
create: (BuildContext context) => sl<CreateRecipeCubit>(),
child: Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: const Text('Добавление рецепта'),
centerTitle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(12),
),
),
),
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: Column(
spacing: 16,
children: [
const InfoAboutRecipeWidget(),
const IngredientsWidget(),
const StepsWidget(),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (context, state) {
return Builder(
builder: (context) {
return InkWell(
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe();
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(
title: state.entity.title ?? 'Рецепт',
),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
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: const Text(
'Опубликовать',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
},
create: (BuildContext context) => createRecipeCubit,
child: BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (BuildContext context, CreateRecipeState state) {
if (state.loading) {
return const Scaffold(
body: Loader(),
);
} else {
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
centerTitle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(12),
),
),
),
],
),
),
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: Column(
spacing: 16,
children: [
InfoAboutRecipeWidget(
createRecipeCubit: createRecipeCubit,
),
const IngredientsWidget(),
StepsWidget(
createRecipeCubit: createRecipeCubit,
),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (context, state) {
return Builder(
builder: (context) {
return InkWell(
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(
title: state.entity.title ?? 'Рецепт',
),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
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(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
},
),
],
),
),
);
}
},
),
);
}

View File

@@ -14,7 +14,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InfoAboutRecipeWidget extends StatefulWidget {
const InfoAboutRecipeWidget({super.key});
final CreateRecipeCubit createRecipeCubit;
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit});
@override
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
@@ -23,13 +25,19 @@ class InfoAboutRecipeWidget extends StatefulWidget {
class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
final List<SelectionPopupModel> items = TypeOfRecipe.values.map((e) => SelectionPopupModel(title: e.toString())).toList();
late SelectionPopupModel selectedType;
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
selectedType = items.first;
createRecipeCubit = context.read<CreateRecipeCubit>();
if (widget.createRecipeCubit.state.entity.typeOfRecipe != null) {
for (int i = 0; i < items.length; i++) {
if (items[i].title == widget.createRecipeCubit.state.entity.typeOfRecipe!.toString()) {
selectedType = items[i];
}
}
} else {
selectedType = items.first;
}
}
@override
@@ -44,6 +52,7 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
const Text('Название'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
bloc: widget.createRecipeCubit,
buildWhen: (previous, current) => previous.titleValidationError != current.titleValidationError,
builder: (context, state) {
return Column(
@@ -51,10 +60,11 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
CustomTextFormField(
hintText: 'Например: тар-тар из говядины',
hasError: state.titleValidationError != null,
initialValue: state.entity.title,
onChanged: (value) {
createRecipeCubit.replaceTitle(value);
widget.createRecipeCubit.replaceTitle(value);
if (state.titleValidationError != null) {
createRecipeCubit.validateTitle();
widget.createRecipeCubit.validateTitle();
}
},
),
@@ -78,7 +88,7 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
setState(() {
selectedType = newSelectedType;
});
createRecipeCubit.changeType(
widget.createRecipeCubit.changeType(
TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other,
);
},
@@ -100,12 +110,13 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
CustomTextFormField(
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
minLines: 5,
initialValue: state.entity.description,
maxLines: 5,
hasError: state.descriptionValidationError != null,
onChanged: (value) {
createRecipeCubit.replaceDescription(value);
widget.createRecipeCubit.replaceDescription(value);
if (state.descriptionValidationError != null) {
createRecipeCubit.validateDescription();
widget.createRecipeCubit.validateDescription();
}
},
),
@@ -148,8 +159,9 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Прикрепите ссылку на видео',
initialValue: widget.createRecipeCubit.state.entity.videoUrl,
onChanged: (value) {
createRecipeCubit.replaceVideoUrl(value);
widget.createRecipeCubit.replaceVideoUrl(value);
},
),
],

View File

@@ -14,6 +14,7 @@ class PortionWidget extends StatefulWidget {
class _PortionWidgetState extends State<PortionWidget> {
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
@@ -45,6 +46,7 @@ class _PortionWidgetState extends State<PortionWidget> {
CustomTextFormField(
hintText: '1',
hasError: state.servingsValidationError != null,
initialValue: state.entity.countOfPortions?.toString(),
textInputType: TextInputType.number,
onChanged: (value) {
createRecipeCubit.replacePortionsCount(int.parse(value));
@@ -77,6 +79,7 @@ class _PortionWidgetState extends State<PortionWidget> {
CustomTextFormField(
hintText: '100',
hasError: state.energyPerServingValidationError != null,
initialValue: state.entity.energyInOnePortion?.toString(),
suffixText: 'Ккал',
textInputType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (value) {

View File

@@ -3,16 +3,16 @@ import 'dart:io';
import 'package:eatvio_app/features/create_recipe/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 CreateRecipeCubit createRecipeCubit;
final int index;
const StepImageWidget({
super.key,
required this.index,
required this.index, required this.createRecipeCubit,
});
@override
@@ -21,12 +21,15 @@ class StepImageWidget extends StatefulWidget {
class _StepImageWidgetState extends State<StepImageWidget> {
XFile? image;
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
createRecipeCubit = context.read<CreateRecipeCubit>();
if (widget.createRecipeCubit.state.entity.steps.length > widget.index) {
if (widget.createRecipeCubit.state.entity.steps[widget.index].image != null) {
image = XFile(widget.createRecipeCubit.state.entity.steps[widget.index].image!.path);
}
}
}
@override
@@ -56,7 +59,7 @@ class _StepImageWidgetState extends State<StepImageWidget> {
setState(() {
image = null;
});
createRecipeCubit.replaceImageInStep(null, widget.index);
widget.createRecipeCubit.replaceImageInStep(null, widget.index);
},
child: Container(
padding: const EdgeInsets.all(4),
@@ -84,7 +87,7 @@ class _StepImageWidgetState extends State<StepImageWidget> {
image = await picker.pickImage(source: ImageSource.camera);
setState(() {});
if (image != null) {
createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
widget.createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
}
if (!context.mounted) return;
Navigator.of(context).pop();
@@ -103,7 +106,7 @@ class _StepImageWidgetState extends State<StepImageWidget> {
image = await picker.pickImage(source: ImageSource.gallery);
setState(() {});
if (image != null) {
createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
widget.createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
}
if (!context.mounted) return;
Navigator.of(context).pop();

View File

@@ -7,24 +7,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class StepsWidget extends StatefulWidget {
const StepsWidget({super.key});
final CreateRecipeCubit createRecipeCubit;
const StepsWidget({super.key, required this.createRecipeCubit});
@override
State<StepsWidget> createState() => _StepsWidgetState();
}
class _StepsWidgetState extends State<StepsWidget> {
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
createRecipeCubit = context.read<CreateRecipeCubit>();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
bloc: widget.createRecipeCubit,
builder: (BuildContext context, CreateRecipeState state) {
return Material(
shape: RoundedRectangleBorder(
@@ -83,10 +78,11 @@ class _StepsWidgetState extends State<StepsWidget> {
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Например: подготовьте ингредиенты',
initialValue: widget.createRecipeCubit.state.entity.steps[index].description,
maxLines: 5,
minLines: 3,
onChanged: (value) {
createRecipeCubit.replaceDescriptionInStep(value, index);
widget.createRecipeCubit.replaceDescriptionInStep(value, index);
},
),
const SizedBox(height: 12),
@@ -94,6 +90,7 @@ class _StepsWidgetState extends State<StepsWidget> {
const SizedBox(height: 4),
StepImageWidget(
index: index,
createRecipeCubit: widget.createRecipeCubit,
),
],
),

View File

@@ -1,6 +1,8 @@
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/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class TagsWidget extends StatefulWidget {
const TagsWidget({super.key});
@@ -10,8 +12,16 @@ class TagsWidget extends StatefulWidget {
}
class _TagsWidgetState extends State<TagsWidget> {
late final CreateRecipeCubit createRecipeCubit;
final tagController = TextEditingController();
final List<String> tags = [];
late final List<String> tags;
@override
void initState() {
super.initState();
createRecipeCubit = context.read<CreateRecipeCubit>();
tags = createRecipeCubit.state.entity.tags;
}
@override
Widget build(BuildContext context) {

View File

@@ -71,6 +71,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
hintText: 'Часов',
hasError: state.cookingTimeValidationError != null,
textInputType: TextInputType.number,
initialValue: state.entity.totalCookHours?.toString(),
onChanged: (value) {
final hours = int.tryParse(value);
if (hours == null) return;
@@ -97,6 +98,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
hintText: 'Минут',
hasError: state.cookingTimeValidationError != null,
textInputType: TextInputType.number,
initialValue: state.entity.totalCookMinutes?.toString(),
onChanged: (value) {
final minutes = int.tryParse(value);
if (minutes == null) return;
@@ -156,6 +158,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
CustomTextFormField(
hintText: 'Часов',
hasError: state.activeTimeValidationError != null,
initialValue: state.entity.activeCookHours?.toString(),
textInputType: TextInputType.number,
onChanged: (value) {
final hours = int.tryParse(value);
@@ -183,6 +186,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
CustomTextFormField(
hintText: 'Минут',
hasError: state.activeTimeValidationError != null,
initialValue: state.entity.activeCookMinutes?.toString(),
textInputType: TextInputType.number,
onChanged: (value) {
final minutes = int.tryParse(value);

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
@@ -11,7 +10,6 @@ import 'package:eatvio_app/features/create_recipe/models/product.dart';
class RecipeRepository {
final CreateRecipeService recipeService;
RecipeRepository({required this.recipeService});
@@ -23,7 +21,7 @@ class RecipeRepository {
}
}
Future<void> publishRecipe(RecipeModel recipe) async {
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async {
try {
if (recipe.image == null || (recipe.image?.isEmpty ?? true)) {
return;
@@ -72,15 +70,11 @@ class RecipeRepository {
};
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty));
final formData = FormData.fromMap(formMap);
await recipeService.publishRecipe(formData: formData);
} catch (e) {
rethrow;
}
}
Future<RecipeModel> editRecipe(RecipeModel recipe, String id) async {
try {
return await recipeService.editRecipe(jsonDecode(jsonEncode(recipe)), id);
if (update) {
await recipeService.updateRecipe(slug: slug!, formData: formData);
} else {
await recipeService.publishRecipe(formData: formData);
}
} catch (e) {
rethrow;
}
@@ -100,5 +94,13 @@ class RecipeRepository {
} catch (e) {
rethrow;
}
}
}
Future<void> deleteRecipe(String slug) async {
try {
return await recipeService.deleteRecipe(slug: slug);
} catch (e) {
rethrow;
}
}
}

View File

@@ -37,11 +37,18 @@ abstract class CreateRecipeService {
@Body() required FormData formData,
});
@POST('/api/recipes/update/{id}')
Future<RecipeModel> editRecipe(@Part() Map<String, Object?> recipe, String id);
@GET('/api/recipes/tags')
Future<RecipeTagModel> getTags();
@DELETE('/api/recipes/delete/{slug}')
Future<void> deleteRecipe({
@Path() required String slug,
});
}
@POST('/api/recipes/update/{slug}')
@MultiPart()
Future<void> updateRecipe({
@Path() required String slug,
@Body() required FormData formData,
});
}

View File

@@ -25,21 +25,40 @@ class Recipe with _$Recipe {
@JsonKey(name: 'was_liked_by_me') required bool wasLikedByMe,
@JsonKey(name: 'is_favorite') required bool isFavorite,
@JsonKey(name: 'energy_per_serving') required double calories,
int? servings,
@JsonKey(name: 'total_likes') required int likesCount,
@JsonKey(name: 'total_views') required int viewsCount,
required List<Tag> tags,
required Author author,
}) = _Recipes;
factory Recipe.fromJson(Map<String, Object?> json) => _$RecipeFromJson(json);
}
@freezed
class Tag with _$Tag {
const factory Tag({required String name}) = _Tag;
factory Tag.fromJson(Map<String, Object?> json) => _$TagFromJson(json);
}
@freezed
class Ingredient with _$Ingredient {
const factory Ingredient({required String name, String? unit, required String portion}) = _Ingredient;
const factory Ingredient({
required String name,
String? unit,
required String portion,
@JsonKey(name: 'product_nutritional_id', fromJson: _parseProductId) required int productId,
}) = _Ingredient;
factory Ingredient.fromJson(Map<String, Object?> json) => _$IngredientFromJson(json);
}
int _parseProductId(json) {
if (json is String) {
return int.parse(json);
}
return json as int;
}
@freezed
class Step with _$Step {
const factory Step({required String image, @JsonKey(name: 'description_md') required String description}) = _Step;

View File

@@ -127,6 +127,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
path: 'recipe/${widget.recipe.slug}',
slug: widget.recipe.slug,
complainedContentType: ComplainedContent.recipe,
recipe: widget.recipe,
),
),
),

View File

@@ -26,7 +26,6 @@ class SubModel with _$SubModel {
@JsonKey(name: 'is_entity_subscribed_by_me') required bool isEntitySubscribedByMe,
@JsonKey(name: 'profile_photo_url') required String profilePhotoUrl,
required List<String> roles,
@JsonKey(name: 'is_public_profile') required bool isPublicProfile,
}) = _SubModel;
factory SubModel.fromJson(Map<String, Object?> json) => _$SubModelFromJson(json);

View File

@@ -2,6 +2,7 @@ import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
@@ -10,20 +11,24 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class ProfileCubit extends Cubit<ProfileState> {
final ProfileRepository profileRepository;
final ChatRepository chatRepository;
final ConsultantRepository consultantRepository;
String _currentPeriod = '1';
ProfileCubit({
required this.profileRepository,
required this.chatRepository,
required this.consultantRepository,
}) : super(const ProfileState.loading());
Future<void> getProfileBySlug(String slug) async {
try {
final profile = await profileRepository.getProfileBySlug(slug);
emit(
ProfileState.loaded(
profile: await profileRepository.getProfileBySlug(slug),
profile: profile,
profileDetails: await profileRepository.getProfileDetailsBySlug(slug),
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: slug),
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(slug) : null,
),
);
} catch (e) {
@@ -44,6 +49,7 @@ class ProfileCubit extends Cubit<ProfileState> {
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
weightPoints: await profileRepository.getWeight(period: period),
circumferencePoints: await profileRepository.getCircumferences(period: period),
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
),
);
} catch (e, stackTrace) {

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
@@ -18,6 +19,7 @@ abstract class ProfileState with _$ProfileState {
required List<FeedBlogPostModel> blogPosts,
List<WeightModel>? weightPoints,
List<CircumferenceModel>? circumferencePoints,
ConsultationRequestModel? consultantInfo,
}) = ProfileStateLoaded;
const factory ProfileState.error({

View File

@@ -93,7 +93,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
} else if (state is ProfileStateError) {
return DataErrorWidget(
onReload: () async {
widget.author == null || widget.author!.authorIsMe ? await profileCubit.getMyProfile() : await profileCubit.getProfileBySlug(widget.author!.authorSlug);
widget.author == null || widget.author!.authorIsMe
? await profileCubit.getMyProfile()
: await profileCubit.getProfileBySlug(widget.author!.authorSlug);
},
);
} else {
@@ -144,25 +146,24 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
),
const SizedBox(width: 12),
if (state.profile.ratingPlace != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF6FAFF),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.profile.ratingPlace!}-й в рейтинге',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: appTheme.blueA400,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF6FAFF),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.consultantInfo!.ratingPlace}-й в рейтинге',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: appTheme.blueA400,
),
),
),
],
),
if (state.profile.educationResource?.hasMedicalEducation == 1)
if (state.consultantInfo!.educationModel.hasMedicalEducation == 1)
const Row(
children: [
SizedBox(width: 16),
@@ -221,6 +222,61 @@ class _ProfileScreenState extends State<ProfileScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_header(state),
if (state.profile.roles.contains('consultant'))
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const SizedBox(width: 16),
const Text(
'Консультант',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(
0xFF323F4B,
),
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF6FAFF),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.consultantInfo!.ratingPlace}-й в рейтинге',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: appTheme.blueA400,
),
),
),
],
),
if (state.consultantInfo!.educationModel.hasMedicalEducation == 1)
const Row(
children: [
SizedBox(width: 16),
Icon(
Icons.done_rounded,
color: Colors.green,
),
Text(
'Медицинское образование',
style: TextStyle(
color: Color(0xFF323F4B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
if (state.profileDetails.additionalInfo.isNotEmpty) _additionalInfo(state),
],
),
@@ -371,7 +427,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
}
_diplomasAndCertificates(ProfileStateLoaded state) {
if (!state.profile.roles.contains('consultant') || state.profile.diplomasAndCertificates == null || state.profile.diplomasAndCertificates!.isEmpty) {
if (!state.profile.roles.contains('consultant') || state.consultantInfo!.photos.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
@@ -416,8 +472,18 @@ class _ProfileScreenState extends State<ProfileScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (state.profile.diplomasAndCertificates != null)
...state.profile.diplomasAndCertificates!.map((d) => _diplomaImage('$baseUrl/storage/${d.path}')),
if (state.consultantInfo!.photos.isNotEmpty)
...List.generate(
state.consultantInfo!.photos.length * 2 - 1,
(index) {
if (index.isEven) {
final photo = state.consultantInfo!.photos[index ~/ 2];
return _diplomaImage(photo.url);
} else {
return const SizedBox(width: 8);
}
},
),
],
),
),
@@ -456,8 +522,8 @@ class _ProfileScreenState extends State<ProfileScreen> {
_education(ProfileStateLoaded state) {
if (!state.profile.roles.contains('consultant') ||
state.profile.educationResource?.completedCourses == null ||
(state.profile.educationResource?.completedCourses?.isEmpty ?? false)) {
state.consultantInfo!.educationModel.completedCourses == null ||
(state.consultantInfo!.educationModel.completedCourses?.isEmpty ?? false)) {
return const SizedBox.shrink();
}
return Padding(
@@ -495,11 +561,17 @@ class _ProfileScreenState extends State<ProfileScreen> {
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.only(left: 16, right: 16, top: 20),
child: Column(
children: [
if (state.profile.educationResource?.completedCourses != null)
...state.profile.educationResource!.completedCourses!.map((c) => _educationBlock(c.values.first.title, c.values.first.date)),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _educationBlock(state.consultantInfo!.educationModel.completedCourses![index].values.first.title,
state.consultantInfo!.educationModel.completedCourses![index].values.first.date),
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemCount: state.consultantInfo!.educationModel.completedCourses!.length,
),
],
),
),
@@ -564,39 +636,39 @@ class _ProfileScreenState extends State<ProfileScreen> {
() {
launchUrl(
Uri.parse(
state.profile.educationResource?.vkLink ?? '',
state.consultantInfo!.educationModel.vkLink ?? '',
),
mode: LaunchMode.externalApplication,
);
},
'assets/icons/vk.svg',
state.profile.educationResource?.vkLink == null ? true : false,
state.consultantInfo!.educationModel.vkLink!.isEmpty ? true : false,
),
const SizedBox(width: 12),
_socialLinkButton(
() {
launchUrl(
Uri.parse(
state.profile.educationResource?.vkLink ?? '',
state.consultantInfo!.educationModel.tgLink ?? '',
),
mode: LaunchMode.externalApplication,
);
},
'assets/icons/telegram.svg',
state.profile.educationResource?.vkLink == null ? true : false,
state.consultantInfo!.educationModel.tgLink!.isEmpty ? true : false,
),
const SizedBox(width: 12),
_socialLinkButton(
() {
launchUrl(
Uri.parse(
state.profile.educationResource?.personalSiteLink ?? '',
state.consultantInfo!.educationModel.personalSiteLink ?? '',
),
mode: LaunchMode.externalApplication,
);
},
'assets/icons/web.svg',
state.profile.educationResource?.personalSiteLink == null ? true : false,
state.consultantInfo!.educationModel.personalSiteLink!.isEmpty ? true : false,
),
],
),
@@ -620,7 +692,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
child: SvgPicture.asset(
icon,
width: 24,
colorFilter: const ColorFilter.mode(Colors.black12, BlendMode.srcIn),
colorFilter: disabled ? const ColorFilter.mode(Colors.black12, BlendMode.srcIn) : const ColorFilter.mode(Colors.black, BlendMode.srcIn),
),
),
),
@@ -659,7 +731,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
for (int i = 0; i < state.profile.badges.length; i++) ...[
state.profile.badges[i].image.contains('svg')
? Tooltip(
message: state.profile.badges[i].description,
message: state.profile.badges[i].description,
triggerMode: TooltipTriggerMode.tap,
textStyle: const TextStyle(
color: Colors.black,

View File

@@ -218,6 +218,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
path: 'recipe/${widget.recipe.slug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipe.slug,
recipe: widget.recipe,
),
const SizedBox(width: 16),
],
@@ -401,6 +402,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
path: 'recipe/${widget.recipe.slug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipe.slug,
recipe: widget.recipe,
),
const SizedBox(width: 16),
],
@@ -425,6 +427,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
path: 'recipe/${widget.recipe.slug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipe.slug,
recipe: widget.recipe,
),
const SizedBox(width: 16),
],

View File

@@ -57,7 +57,7 @@ class RegistrationCubit extends Cubit<RegistrationState> {
),
);
await TokenStorage.saveTokenInStorage(token);
AppRouter.navigatorKey.currentContext?.go('/my-profile-screen');
AppRouter.navigatorKey.currentContext?.go('/feed-screen');
} catch (e) {
log(e.toString());
}

View File

@@ -25,8 +25,8 @@ class UpdateScreen extends StatelessWidget {
fit: BoxFit.cover,
),
),
SafeArea(
child: Positioned.fill(
Positioned.fill(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(

View File

@@ -37,8 +37,8 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
fit: BoxFit.cover,
),
),
SafeArea(
child: Positioned.fill(
Positioned.fill(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
@@ -80,7 +80,7 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
onTap: () async {
final sharedPreferences = await SharedPreferences.getInstance();
await sharedPreferences.setBool('welcomeScreenWasShowed', true);
goRouter.go('/my-profile-screen');
goRouter.go('/feed-screen');
},
borderRadius: BorderRadius.circular(8),
child: Ink(