feat: implement Iteration 4 — menu planning, shopping list, diary

Backend:
- Migrations 007 (menu_plans, menu_items, shopping_lists) and
  008 (meal_diary)
- gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call
- internal/menu: model, repository (GetByWeek, SaveMenuInTx,
  shopping list CRUD), handler (GET/PUT/DELETE /menu,
  POST /ai/generate-menu, shopping list endpoints)
- internal/diary: model, repository, handler (GET/POST/DELETE /diary)
- Increase server WriteTimeout to 120s for long AI calls
- api_client.go: add patch() and postList() helpers

Flutter:
- shared/models: menu.dart, shopping_item.dart, diary_entry.dart
- features/menu: menu_service.dart, menu_provider.dart
  (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family)
- MenuScreen: 7-day view, week nav, skeleton on generation,
  generate FAB with confirmation dialog
- ShoppingListScreen: items by category, optimistic checkbox toggle
- DiaryScreen: daily entries with swipe-to-delete, add-entry sheet
- Router: /menu/shopping-list and /menu/diary routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-22 12:00:25 +02:00
parent 612a0eda60
commit ea8e207a45
22 changed files with 2926 additions and 12 deletions

View File

@@ -1,13 +1,468 @@
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';
class MenuScreen extends StatelessWidget {
import '../../shared/models/menu.dart';
import 'menu_provider.dart';
class MenuScreen extends ConsumerWidget {
const MenuScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final week = ref.watch(currentWeekProvider);
final state = ref.watch(menuProvider(week));
return Scaffold(
appBar: AppBar(title: const Text('Меню')),
body: const Center(child: Text('Раздел в разработке')),
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('Список покупок'),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () {
final today = DateTime.now();
final dateStr =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
context.push('/menu/diary', extra: dateStr);
},
icon: const Icon(Icons.book_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: (_) => AlertDialog(
title: Text('Изменить ${slot.mealLabel.toLowerCase()}?'),
content: const Text(
'Удалите текущий рецепт из слота — новый появится после следующей генерации.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
if (slot.recipe != null)
TextButton(
onPressed: () async {
Navigator.pop(context);
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: (_) => AlertDialog(
title: const Text('Сгенерировать меню?'),
content: const Text(
'Gemini составит меню на неделю с учётом ваших продуктов и целей. Текущее меню будет заменено.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
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('Повторить')),
],
),
);
}
}