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:
@@ -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('Повторить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user