feat(diary): кбжу

This commit is contained in:
mso7nueh
2025-09-08 14:14:41 +03:00
parent bf6e23e96a
commit b6b1c8b3c1
10 changed files with 461 additions and 398 deletions

View File

@@ -1,16 +1,15 @@
import 'package:collection/collection.dart';
import 'package:eatvio_app/core/theme/theme_helper.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';
import 'package:eatvio_app/features/diary/presentation/widgets/meal_summary_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MealButtonWidget extends StatefulWidget {
final int mealNumber;
const MealButtonWidget({
super.key,
required this.mealNumber,
});
const MealButtonWidget({super.key, required this.mealNumber});
@override
State<MealButtonWidget> createState() => _MealButtonWidgetState();
@@ -22,9 +21,7 @@ class _MealButtonWidgetState extends State<MealButtonWidget> {
return BlocBuilder<DiaryCubit, DiaryState>(
builder: (context, state) {
if (state is DiaryStateLoaded) {
final consultantComment = state.consultantReview?.consultantComments.mealsComments.firstWhereOrNull(
(element) => element.typeNo == widget.mealNumber,
);
final consultantComment = state.consultantReview?.consultantComments.mealsComments.firstWhereOrNull((element) => element.typeNo == widget.mealNumber);
final Color bgColor;
if (state.selectedMeal == widget.mealNumber) {
@@ -34,55 +31,84 @@ class _MealButtonWidgetState extends State<MealButtonWidget> {
} else {
bgColor = const Color(0xFFF1F2F6);
}
return InkWell(
onTap: () {
context.read<DiaryCubit>().setSelectedMeal(widget.mealNumber);
},
borderRadius: BorderRadius.circular(100),
child: Ink(
width: MediaQuery.of(context).size.width / 5 - (96 / 5),
height: 32,
decoration: BoxDecoration(
final mealIndex = widget.mealNumber - 1;
final portions = mealIndex < state.diary.meals.length ? state.diary.meals[mealIndex].portions : <PortionModel>[];
final totals = MealSummaryWidget.calculateMealTotals(portions);
final calories = totals['calories']!.round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () {
context.read<DiaryCubit>().setSelectedMeal(widget.mealNumber);
},
borderRadius: BorderRadius.circular(100),
color: bgColor,
),
child: Center(
child: Text(
'${widget.mealNumber}',
style: TextStyle(
color: state.selectedMeal == widget.mealNumber ? Colors.white : const Color(0xFF6D6F71),
fontSize: 16,
fontWeight: FontWeight.w600,
child: Ink(
width: MediaQuery.of(context).size.width / 5 - (96 / 5),
height: 32,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(100), color: bgColor),
child: Center(
child: Text(
'${widget.mealNumber}',
style: TextStyle(
color: state.selectedMeal == widget.mealNumber ? Colors.white : const Color(0xFF6D6F71),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
const SizedBox(height: 4),
Text(
calories > 0 ? '$calories ккал' : '— ккал',
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
),
],
);
}
if (state is DiaryStateLoadedTomorrowWithDiet) {
return InkWell(
onTap: () {
context.read<DiaryCubit>().setSelectedMeal(widget.mealNumber);
},
borderRadius: BorderRadius.circular(100),
child: Ink(
width: MediaQuery.of(context).size.width / 5 - (96 / 5),
height: 32,
decoration: BoxDecoration(
final mealIndex = widget.mealNumber - 1;
final portions = mealIndex < state.diary.meals.length ? state.diary.meals[mealIndex].portions : <PortionModel>[];
final totals = MealSummaryWidget.calculateMealTotals(portions);
final calories = totals['calories']!.round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () {
context.read<DiaryCubit>().setSelectedMeal(widget.mealNumber);
},
borderRadius: BorderRadius.circular(100),
color: state.selectedMeal == widget.mealNumber ? appTheme.blueA400 : const Color(0xFFF1F2F6),
),
child: Center(
child: Text(
'${widget.mealNumber}',
style: TextStyle(
color: state.selectedMeal == widget.mealNumber ? Colors.white : const Color(0xFF6D6F71),
fontSize: 16,
fontWeight: FontWeight.w600,
child: Ink(
width: MediaQuery.of(context).size.width / 5 - (96 / 5),
height: 32,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(100),
color: state.selectedMeal == widget.mealNumber ? appTheme.blueA400 : const Color(0xFFF1F2F6),
),
child: Center(
child: Text(
'${widget.mealNumber}',
style: TextStyle(
color: state.selectedMeal == widget.mealNumber ? Colors.white : const Color(0xFF6D6F71),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
const SizedBox(height: 4),
Text(
calories > 0 ? '$calories ккал' : '— ккал',
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
),
],
);
}
return const SizedBox.shrink();

View File

@@ -0,0 +1,112 @@
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:flutter/material.dart';
class MealSummaryWidget extends StatelessWidget {
final List<PortionModel> portions;
final int mealNumber;
const MealSummaryWidget({super.key, required this.portions, required this.mealNumber});
@override
Widget build(BuildContext context) {
final totals = _calculateTotals();
if (totals['calories'] == 0 && totals['proteins'] == 0 && totals['fats'] == 0 && totals['carbohydrates'] == 0) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2F80ED), width: 2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Прием $mealNumber',
style: const TextStyle(color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
Row(
children: [
_buildNutritionChip('К', totals['calories']!.round()),
const SizedBox(width: 4),
_buildNutritionChip('Б', totals['proteins']!.round()),
const SizedBox(width: 4),
_buildNutritionChip('Ж', totals['fats']!.round()),
const SizedBox(width: 4),
_buildNutritionChip('У', totals['carbohydrates']!.round()),
],
),
],
),
);
}
Widget _buildNutritionChip(String label, int value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(12)),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '$label ',
style: const TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w600),
),
TextSpan(
text: '$value',
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
),
);
}
Map<String, double> _calculateTotals() {
return calculateMealTotals(portions);
}
static Map<String, double> calculateMealTotals(List<PortionModel> portions) {
double totalCalories = 0;
double totalProteins = 0;
double totalFats = 0;
double totalCarbohydrates = 0;
for (final portion in portions) {
if (portion.portionState == PortionState.eatenDeleted) {
continue;
}
final caloriesInfo = portion.productCaloriesInfo;
if (caloriesInfo == null) {
continue;
}
double calories, proteins, fats, carbohydrates;
if (portion.portion == 'gr') {
calories = (caloriesInfo.calories * portion.quantity) / 100;
proteins = (caloriesInfo.proteins * portion.quantity) / 100;
fats = (caloriesInfo.fats * portion.quantity) / 100;
carbohydrates = (caloriesInfo.carbohydrates * portion.quantity) / 100;
} else {
calories = caloriesInfo.energyOnPortion;
proteins = caloriesInfo.proteinsOnPortion;
fats = caloriesInfo.fatsOnPortion;
carbohydrates = caloriesInfo.carbohydratesOnPortion;
}
totalCalories += calories;
totalProteins += proteins;
totalFats += fats;
totalCarbohydrates += carbohydrates;
}
return {'calories': totalCalories, 'proteins': totalProteins, 'fats': totalFats, 'carbohydrates': totalCarbohydrates};
}
}

View File

@@ -40,11 +40,7 @@ class _MealWidgetState extends State<MealWidget> {
children: [
const Text(
'Прием пищи',
style: TextStyle(
color: Color(0xFF323F4B),
fontSize: 18,
fontWeight: FontWeight.w500,
),
style: TextStyle(color: Color(0xFF323F4B), fontSize: 18, fontWeight: FontWeight.w500),
),
const SizedBox(height: 16),
const Row(
@@ -57,23 +53,17 @@ class _MealWidgetState extends State<MealWidget> {
MealButtonWidget(mealNumber: 5),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16),
const TimeOfMealPickerWidget(),
const ChosenDietWidget(),
const ChosenPlanWidget(),
const ProductsListWidget(),
if (widget.showAddButton) ...[
const SizedBox(height: 12),
const AddProductButtonWidget(),
],
if (widget.showAddButton) ...[const SizedBox(height: 12), const AddProductButtonWidget()],
const SizedBox(height: 12),
const PhotoGalleryWidget(),
const SizedBox(height: 12),
const SatietyRatingWidget(),
if (widget.showMood) ...[
const SizedBox(height: 12),
const MoodRatingWidget(),
],
if (widget.showMood) ...[const SizedBox(height: 12), const MoodRatingWidget()],
BlocBuilder<DiaryCubit, DiaryState>(
builder: (context, state) {
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) {
@@ -91,10 +81,7 @@ class _MealWidgetState extends State<MealWidget> {
if (consultantCommentOfMeal != null) {
return Column(
children: [
Text(
'${s.mealComment} ${state.selectedMeal}',
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text('${s.mealComment} ${state.selectedMeal}', style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 12),
CommentContainer(comment: consultantCommentOfMeal.comment),
],
@@ -103,7 +90,7 @@ class _MealWidgetState extends State<MealWidget> {
}
return const SizedBox.shrink();
},
)
),
],
),
),

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -15,10 +16,7 @@ import 'package:flutter_svg/flutter_svg.dart';
class ProductWidget extends StatefulWidget {
final PortionModel portion;
const ProductWidget({
super.key,
required this.portion,
});
const ProductWidget({super.key, required this.portion});
@override
State<ProductWidget> createState() => _ProductWidgetState();
@@ -37,6 +35,60 @@ class _ProductWidgetState extends State<ProductWidget> {
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
}
Widget _buildNutritionalInfo() {
if (widget.portion.productNutritionalValueId == null) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: const Text(
'Продукт отправлен на модерацию',
style: TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w500),
),
);
}
final caloriesInfo = widget.portion.productCaloriesInfo;
if (caloriesInfo == null) {
return const SizedBox.shrink();
}
double calories, proteins, fats, carbohydrates;
if (widget.portion.portion == 'gr') {
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
fats = (caloriesInfo.fats * widget.portion.quantity) / 100;
carbohydrates = (caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
} else {
calories = caloriesInfo.energyOnPortion;
proteins = caloriesInfo.proteinsOnPortion;
fats = caloriesInfo.fatsOnPortion;
carbohydrates = caloriesInfo.carbohydratesOnPortion;
}
return Row(
children: [
_buildNutritionChip('К', calories.round()),
const SizedBox(width: 4),
_buildNutritionChip('Б', proteins.round()),
const SizedBox(width: 4),
_buildNutritionChip('Ж', fats.round()),
const SizedBox(width: 4),
_buildNutritionChip('У', carbohydrates.round()),
],
);
}
Widget _buildNutritionChip(String label, int value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
child: Text(
'$label $value',
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
),
);
}
@override
void didUpdateWidget(ProductWidget oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -48,15 +100,9 @@ class _ProductWidgetState extends State<ProductWidget> {
return showModalBottomSheet(
useRootNavigator: true,
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
isScrollControlled: true,
builder: (context) => AddProductModal(
state: state,
portion: portion,
diaryCubit: diaryCubit,
),
builder: (context) => AddProductModal(state: state, portion: portion, diaryCubit: diaryCubit),
);
}
@@ -69,15 +115,21 @@ class _ProductWidgetState extends State<ProductWidget> {
horizontalTitleGap: 0,
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 12) : null,
title: Text(widget.portion.productName),
subtitle: Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
const SizedBox(height: 4),
_buildNutritionalInfo(),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
tileColor: const Color(0xFFF6FAFF),
);
}
if (state is DiaryStateLoaded) {
final isDiaryCommented = state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
final isDiaryCommented =
state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
final isDiaryFinished =
@@ -99,21 +151,20 @@ class _ProductWidgetState extends State<ProductWidget> {
color: Color(0xFF6D6F71),
),
child: ListTile(
onTap: () => diaryCubit.restore(
diaryId: state.diary.id,
mealId: state.selectedMeal,
portionId: widget.portion.id,
),
trailing: SvgPicture.asset(
'assets/icons/revert_product.svg',
),
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
horizontalTitleGap: 0,
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
title: Text(widget.portion.productName),
subtitle: Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
const SizedBox(height: 4),
_buildNutritionalInfo(),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
// ignore: use_full_hex_values_for_flutter_colors
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
),
@@ -122,12 +173,13 @@ class _ProductWidgetState extends State<ProductWidget> {
Widget? trailing;
if (state.isConsultantView && !isDiaryCommented) {
trailing = GestureDetector(
onTap: () {
setState(() {
showCommentWidget = !showCommentWidget;
});
},
child: SvgPicture.asset('assets/icons/edit.svg'));
onTap: () {
setState(() {
showCommentWidget = !showCommentWidget;
});
},
child: SvgPicture.asset('assets/icons/edit.svg'),
);
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
trailing = const Icon(Icons.more_vert_rounded);
}
@@ -147,47 +199,36 @@ class _ProductWidgetState extends State<ProductWidget> {
isEaten = value!;
});
if (isEaten) {
diaryCubit.markEaten(
diaryId: state.diary.id,
mealId: state.selectedMeal,
portionId: widget.portion.id,
);
diaryCubit.markEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
} else {
diaryCubit.unmarkEaten(
diaryId: state.diary.id,
mealId: state.selectedMeal,
portionId: widget.portion.id,
);
diaryCubit.unmarkEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
}
}
: null,
activeColor: appTheme.blueA400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: const BorderSide(
color: Color(0xFF809CB9),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
side: const BorderSide(color: Color(0xFF809CB9)),
),
)
: null,
horizontalTitleGap: 0,
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
title: Text(widget.portion.productName),
subtitle: Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
const SizedBox(height: 4),
_buildNutritionalInfo(),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
tileColor: const Color(0xFFF6FAFF),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: PortionCommentWidget(
enabled: showCommentWidget,
portionId: widget.portion.id,
comment: portionComment?.comment,
),
)
child: PortionCommentWidget(enabled: showCommentWidget, portionId: widget.portion.id, comment: portionComment?.comment),
),
],
);
}

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/meal_summary_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/product_loader_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/product_widget.dart';
import 'package:flutter/material.dart';
@@ -16,33 +17,33 @@ class _ProductsListWidgetState extends State<ProductsListWidget> {
@override
Widget build(BuildContext context) {
return BlocBuilder<DiaryCubit, DiaryState>(
builder: (BuildContext context, DiaryState state) {
builder: (BuildContext context, DiaryState state) {
if (state is DiaryStateLoaded) {
int mealIndex = state.selectedMeal - 1;
final portions = state.diary.meals[mealIndex].portions;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < state.diary.meals[mealIndex].portions.length; i++) ...[
ProductWidget(portion: state.diary.meals[mealIndex].portions[i]),
i < state.diary.meals[mealIndex].portions.length - 1 ? const SizedBox(height: 8) : const SizedBox.shrink(),
for (int i = 0; i < portions.length; i++) ...[
ProductWidget(portion: portions[i]),
i < portions.length - 1 ? const SizedBox(height: 8) : const SizedBox.shrink(),
],
if (state.isLoadingAddingProductInMeal)
const Padding(
padding: EdgeInsets.only(top: 8),
child: ProductLoaderWidget(),
),
if (portions.isNotEmpty) MealSummaryWidget(portions: portions, mealNumber: state.selectedMeal),
if (state.isLoadingAddingProductInMeal) const Padding(padding: EdgeInsets.only(top: 8), child: ProductLoaderWidget()),
],
);
}
if (state is DiaryStateLoadedTomorrowWithDiet) {
int mealIndex = state.selectedMeal - 1;
final portions = state.diary.meals[mealIndex].portions;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < state.diary.meals[mealIndex].portions.length; i++) ...[
ProductWidget(portion: state.diary.meals[mealIndex].portions[i]),
i < state.diary.meals[mealIndex].portions.length - 1 ? const SizedBox(height: 8) : const SizedBox.shrink(),
for (int i = 0; i < portions.length; i++) ...[
ProductWidget(portion: portions[i]),
i < portions.length - 1 ? const SizedBox(height: 8) : const SizedBox.shrink(),
],
if (portions.isNotEmpty) MealSummaryWidget(portions: portions, mealNumber: state.selectedMeal),
],
);
}