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>
455 lines
14 KiB
Dart
455 lines
14 KiB
Dart
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('Повторить')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|