Files
food-ai/client/lib/features/menu/menu_screen.dart
dbastrikin ad00998344 feat: slim meal_diary — derive name and nutrition from dish/recipe
Remove denormalized columns (name, calories, protein_g, fat_g, carbs_g)
from meal_diary. Name is now resolved via JOIN with dishes/dish_translations;
macros are computed as recipe.*_per_serving * portions at query time.

- Add dish.Repository.FindOrCreateRecipe: finds or creates a minimal recipe
  stub seeded with AI-estimated macros
- recognition/handler: resolve recipe_id synchronously per candidate;
  simplify enrichDishInBackground to translations-only
- diary/handler: accept dish_id OR name; always resolve recipe_id via
  FindOrCreateRecipe before INSERT
- diary/entity: DishID is now non-nullable string; CreateRequest drops macros
- diary/repository: ListByDate and Create use JOIN to return computed macros
- ai/types: add RecipeID field to DishCandidate
- Update tests and wire_gen accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:28:37 +02:00

455 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/models/menu.dart';
import 'menu_provider.dart';
class MenuScreen extends ConsumerWidget {
const MenuScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final week = ref.watch(currentWeekProvider);
final state = ref.watch(menuProvider(week));
return Scaffold(
appBar: AppBar(
title: const Text('Меню'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(menuProvider(week).notifier).load(),
),
],
bottom: _WeekNavBar(week: week, ref: ref),
),
body: state.when(
loading: () => const _MenuSkeleton(),
error: (err, _) => _ErrorView(
onRetry: () => ref.read(menuProvider(week).notifier).load(),
),
data: (plan) => plan == null
? _EmptyState(
onGenerate: () =>
ref.read(menuProvider(week).notifier).generate(),
)
: _MenuContent(plan: plan, week: week),
),
floatingActionButton: state.maybeWhen(
data: (_) => _GenerateFab(week: week),
orElse: () => null,
),
);
}
}
// ── Week navigation app-bar bottom ────────────────────────────
class _WeekNavBar extends StatelessWidget implements PreferredSizeWidget {
final String week;
final WidgetRef ref;
const _WeekNavBar({required this.week, required this.ref});
@override
Size get preferredSize => const Size.fromHeight(40);
String _weekLabel(String week) {
try {
final parts = week.split('-W');
if (parts.length != 2) return week;
final year = int.parse(parts[0]);
final w = int.parse(parts[1]);
final monday = _mondayOfISOWeek(year, w);
final sunday = monday.add(const Duration(days: 6));
const months = [
'янв', 'фев', 'мар', 'апр', 'май', 'июн',
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
];
return '${monday.day}${sunday.day} ${months[sunday.month - 1]}';
} catch (_) {
return week;
}
}
DateTime _mondayOfISOWeek(int year, int w) {
final jan4 = DateTime.utc(year, 1, 4);
final monday1 = jan4.subtract(Duration(days: jan4.weekday - 1));
return monday1.add(Duration(days: (w - 1) * 7));
}
String _offsetWeek(String week, int offsetWeeks) {
try {
final parts = week.split('-W');
final year = int.parse(parts[0]);
final w = int.parse(parts[1]);
final monday = _mondayOfISOWeek(year, w);
final newMonday = monday.add(Duration(days: offsetWeeks * 7));
final (ny, nw) = _isoWeekOf(newMonday);
return '$ny-W${nw.toString().padLeft(2, '0')}';
} catch (_) {
return week;
}
}
(int, int) _isoWeekOf(DateTime dt) {
final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday)));
final jan1 = DateTime.utc(thu.year, 1, 1);
final w = ((thu.difference(jan1).inDays) / 7).ceil();
return (thu.year, w);
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, -1);
},
),
Text(_weekLabel(week), style: Theme.of(context).textTheme.bodyMedium),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, 1);
},
),
],
);
}
}
// ── Menu content ──────────────────────────────────────────────
class _MenuContent extends StatelessWidget {
final MenuPlan plan;
final String week;
const _MenuContent({required this.plan, required this.week});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.only(bottom: 120),
children: [
...plan.days.map((day) => _DayCard(day: day, week: week)),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () =>
context.push('/menu/shopping-list', extra: week),
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('Список покупок'),
),
),
],
);
}
}
class _DayCard extends StatelessWidget {
final MenuDay day;
final String week;
const _DayCard({required this.day, required this.week});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${day.dayName}, ${day.shortDate}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
const Spacer(),
Text(
'${day.totalCalories.toInt()} ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
),
const SizedBox(height: 8),
Card(
child: Column(
children: day.meals.asMap().entries.map((entry) {
final index = entry.key;
final slot = entry.value;
return Column(
children: [
_MealRow(slot: slot, week: week),
if (index < day.meals.length - 1)
const Divider(height: 1),
],
);
}).toList(),
),
),
],
),
);
}
}
class _MealRow extends ConsumerWidget {
final MealSlot slot;
final String week;
const _MealRow({required this.slot, required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final recipe = slot.recipe;
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: recipe?.imageUrl.isNotEmpty == true
? CachedNetworkImage(
imageUrl: recipe!.imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => _placeholder(),
)
: _placeholder(),
),
title: Row(
children: [
Text(slot.mealEmoji, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 4),
Text(slot.mealLabel, style: theme.textTheme.labelMedium),
if (recipe?.nutrition?.calories != null) ...[
const Spacer(),
Text(
'${recipe!.nutrition!.calories.toInt()} ккал',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
],
),
subtitle: recipe != null
? Text(recipe.title, style: theme.textTheme.bodyMedium)
: Text(
'Не задано',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
trailing: TextButton(
onPressed: () => _showChangeDialog(context, ref),
child: const Text('Изменить'),
),
);
}
Widget _placeholder() => Container(
width: 56,
height: 56,
color: Colors.grey.shade200,
child: const Icon(Icons.restaurant, color: Colors.grey),
);
void _showChangeDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Изменить ${slot.mealLabel.toLowerCase()}?'),
content: const Text(
'Удалите текущий рецепт из слота — новый появится после следующей генерации.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
if (slot.recipe != null)
TextButton(
onPressed: () async {
Navigator.pop(ctx);
await ref
.read(menuProvider(week).notifier)
.deleteItem(slot.id);
},
child: const Text('Убрать рецепт'),
),
],
),
);
}
}
// ── Generate FAB ──────────────────────────────────────────────
class _GenerateFab extends ConsumerWidget {
final String week;
const _GenerateFab({required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
return FloatingActionButton.extended(
onPressed: () => _confirmGenerate(context, ref),
icon: const Icon(Icons.auto_awesome),
label: const Text('Сгенерировать меню'),
);
}
void _confirmGenerate(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Сгенерировать меню?'),
content: const Text(
'Gemini составит меню на неделю с учётом ваших продуктов и целей. Текущее меню будет заменено.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
ref.read(menuProvider(week).notifier).generate();
},
child: const Text('Сгенерировать'),
),
],
),
);
}
}
// ── Skeleton ──────────────────────────────────────────────────
class _MenuSkeleton extends StatelessWidget {
const _MenuSkeleton();
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.surfaceContainerHighest;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Составляем меню на неделю...'),
SizedBox(height: 4),
Text(
'Учитываем ваши продукты и цели',
style: TextStyle(color: Colors.grey),
),
],
),
),
for (int i = 0; i < 3; i++) ...[
_shimmer(color, height: 16, width: 160),
const SizedBox(height: 8),
_shimmer(color, height: 90),
const SizedBox(height: 16),
],
],
);
}
Widget _shimmer(Color color, {double height = 60, double? width}) =>
Container(
height: height,
width: width,
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
);
}
// ── Empty state ───────────────────────────────────────────────
class _EmptyState extends StatelessWidget {
final VoidCallback onGenerate;
const _EmptyState({required this.onGenerate});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today_outlined,
size: 72, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('Меню не составлено',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Нажмите кнопку ниже, чтобы Gemini составил меню на неделю с учётом ваших продуктов',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onGenerate,
icon: const Icon(Icons.auto_awesome),
label: const Text('Сгенерировать меню'),
),
],
),
),
);
}
}
// ── Error view ────────────────────────────────────────────────
class _ErrorView extends StatelessWidget {
final VoidCallback onRetry;
const _ErrorView({required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text('Не удалось загрузить меню'),
const SizedBox(height: 12),
FilledButton(
onPressed: onRetry, child: const Text('Повторить')),
],
),
);
}
}