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

@@ -38,6 +38,17 @@ class ApiClient {
return response.data;
}
Future<void> patch(String path,
{dynamic data, Map<String, dynamic>? params}) async {
await _dio.patch(path, data: data, queryParameters: params);
}
/// Posts data and expects a JSON array response.
Future<List<dynamic>> postList(String path, {dynamic data}) async {
final response = await _dio.post(path, data: data);
return response.data as List<dynamic>;
}
Future<Map<String, dynamic>> delete(String path) async {
final response = await _dio.delete(path);
return response.data;

View File

@@ -12,7 +12,9 @@ import '../../features/scan/scan_screen.dart';
import '../../features/scan/recognition_confirm_screen.dart';
import '../../features/scan/dish_result_screen.dart';
import '../../features/scan/recognition_service.dart';
import '../../features/menu/diary_screen.dart';
import '../../features/menu/menu_screen.dart';
import '../../features/menu/shopping_list_screen.dart';
import '../../features/recipes/recipe_detail_screen.dart';
import '../../features/recipes/recipes_screen.dart';
import '../../features/profile/profile_screen.dart';
@@ -62,6 +64,22 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/products/add',
builder: (_, __) => const AddProductScreen(),
),
// Shopping list — full-screen, no bottom nav.
GoRoute(
path: '/menu/shopping-list',
builder: (context, state) {
final week = state.extra as String? ?? '';
return ShoppingListScreen(week: week);
},
),
// Diary — full-screen, no bottom nav.
GoRoute(
path: '/menu/diary',
builder: (context, state) {
final date = state.extra as String? ?? '';
return DiaryScreen(date: date);
},
),
// Scan / recognition flow — all without bottom nav.
GoRoute(
path: '/scan',

View File

@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/models/diary_entry.dart';
import 'menu_provider.dart';
String formatDate(String d) {
try {
final dt = DateTime.parse(d);
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
return '${dt.day} ${months[dt.month - 1]}';
} catch (_) {
return d;
}
}
class DiaryScreen extends ConsumerWidget {
final String date;
const DiaryScreen({super.key, required this.date});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(diaryProvider(date));
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Дневник — ${formatDate(date)}'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddSheet(context, ref),
child: const Icon(Icons.add),
),
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: FilledButton(
onPressed: () => ref.read(diaryProvider(date).notifier).load(),
child: const Text('Повторить'),
),
),
data: (entries) => entries.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.book_outlined,
size: 72,
color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
const Text('Нет записей за этот день'),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () => _showAddSheet(context, ref),
icon: const Icon(Icons.add),
label: const Text('Добавить запись'),
),
],
),
)
: _DiaryList(entries: entries, date: date),
),
);
}
void _showAddSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => _AddEntrySheet(date: date, ref: ref),
);
}
}
class _DiaryList extends ConsumerWidget {
final List<DiaryEntry> entries;
final String date;
const _DiaryList({required this.entries, required this.date});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Group by meal type.
const order = ['breakfast', 'lunch', 'dinner'];
final grouped = <String, List<DiaryEntry>>{};
for (final e in entries) {
grouped.putIfAbsent(e.mealType, () => []).add(e);
}
// Total calories for the day.
final totalCal = entries.fold<double>(
0,
(sum, e) => sum + ((e.calories ?? 0) * e.portions),
);
return ListView(
padding: const EdgeInsets.only(bottom: 100),
children: [
if (totalCal > 0)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
'Итого за день: ≈${totalCal.toInt()} ккал',
style: Theme.of(context).textTheme.titleSmall,
),
),
for (final mealType in [...order, ...grouped.keys.where((k) => !order.contains(k))])
if (grouped.containsKey(mealType)) ...[
_MealHeader(mealType: mealType),
for (final entry in grouped[mealType]!)
_EntryTile(entry: entry, date: date),
],
],
);
}
}
class _MealHeader extends StatelessWidget {
final String mealType;
const _MealHeader({required this.mealType});
@override
Widget build(BuildContext context) {
final entry = DiaryEntry(
id: '', date: '', mealType: mealType, name: '',
portions: 1, source: '',
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
entry.mealLabel,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
);
}
}
class _EntryTile extends ConsumerWidget {
final DiaryEntry entry;
final String date;
const _EntryTile({required this.entry, required this.date});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cal = entry.calories != null
? '${(entry.calories! * entry.portions).toInt()} ккал'
: '';
return Dismissible(
key: ValueKey(entry.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
onDismissed: (_) =>
ref.read(diaryProvider(date).notifier).remove(entry.id),
child: ListTile(
title: Text(entry.name),
subtitle: entry.portions != 1
? Text('${entry.portions} порций')
: null,
trailing: cal.isNotEmpty
? Text(cal,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(fontWeight: FontWeight.w600))
: null,
),
);
}
}
// ── Add entry bottom sheet ─────────────────────────────────────
class _AddEntrySheet extends StatefulWidget {
final String date;
final WidgetRef ref;
const _AddEntrySheet({required this.date, required this.ref});
@override
State<_AddEntrySheet> createState() => _AddEntrySheetState();
}
class _AddEntrySheetState extends State<_AddEntrySheet> {
final _nameController = TextEditingController();
final _calController = TextEditingController();
String _mealType = 'breakfast';
bool _saving = false;
static const _mealTypes = [
('breakfast', 'Завтрак'),
('lunch', 'Обед'),
('dinner', 'Ужин'),
];
@override
void dispose() {
_nameController.dispose();
_calController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final insets = MediaQuery.viewInsetsOf(context);
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Добавить запись',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
DropdownMenu<String>(
initialSelection: _mealType,
expandedInsets: EdgeInsets.zero,
label: const Text('Приём пищи'),
dropdownMenuEntries: _mealTypes
.map((t) => DropdownMenuEntry(value: t.$1, label: t.$2))
.toList(),
onSelected: (v) => setState(() => _mealType = v ?? _mealType),
),
const SizedBox(height: 12),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Название блюда',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _calController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Калории (необязательно)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Добавить'),
),
],
),
);
}
Future<void> _save() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
setState(() => _saving = true);
try {
final cal = double.tryParse(_calController.text);
await widget.ref.read(diaryProvider(widget.date).notifier).add({
'date': widget.date,
'meal_type': _mealType,
'name': name,
'portions': 1,
if (cal != null) 'calories': cal,
'source': 'manual',
});
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _saving = false);
}
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../shared/models/diary_entry.dart';
import '../../shared/models/menu.dart';
import '../../shared/models/shopping_item.dart';
import 'menu_service.dart';
// ── Service provider ──────────────────────────────────────────
final menuServiceProvider = Provider<MenuService>((ref) {
return MenuService(ref.read(apiClientProvider));
});
// ── Current week (state) ──────────────────────────────────────
/// The ISO week string for the currently displayed week, e.g. "2026-W08".
final currentWeekProvider = StateProvider<String>((ref) {
final now = DateTime.now().toUtc();
final (y, w) = _isoWeek(now);
return '$y-W${w.toString().padLeft(2, '0')}';
});
(int year, int week) _isoWeek(DateTime dt) {
// Shift to Thursday to get ISO week year.
final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday)));
final jan1 = DateTime.utc(thu.year, 1, 1);
final week = ((thu.difference(jan1).inDays) / 7).ceil();
return (thu.year, week);
}
// ── Menu notifier ─────────────────────────────────────────────
class MenuNotifier extends StateNotifier<AsyncValue<MenuPlan?>> {
final MenuService _service;
final String _week;
MenuNotifier(this._service, this._week) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getMenu(week: _week));
}
Future<void> generate() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.generateMenu(week: _week));
}
Future<void> updateItem(String itemId, String recipeId) async {
await _service.updateMenuItem(itemId, recipeId);
await load();
}
Future<void> deleteItem(String itemId) async {
await _service.deleteMenuItem(itemId);
await load();
}
}
final menuProvider =
StateNotifierProvider.family<MenuNotifier, AsyncValue<MenuPlan?>, String>(
(ref, week) => MenuNotifier(ref.read(menuServiceProvider), week),
);
// ── Shopping list notifier ────────────────────────────────────
class ShoppingListNotifier extends StateNotifier<AsyncValue<List<ShoppingItem>>> {
final MenuService _service;
final String _week;
ShoppingListNotifier(this._service, this._week)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getShoppingList(week: _week));
}
Future<void> regenerate() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => _service.generateShoppingList(week: _week));
}
Future<void> toggle(int index, bool checked) async {
// Optimistic update.
state = state.whenData((items) {
final list = List<ShoppingItem>.from(items);
if (index < list.length) {
list[index] = list[index].copyWith(checked: checked);
}
return list;
});
try {
await _service.toggleShoppingItem(index, checked, week: _week);
} catch (_) {
// Revert on failure.
await load();
}
}
}
final shoppingListProvider = StateNotifierProvider.family<
ShoppingListNotifier, AsyncValue<List<ShoppingItem>>, String>(
(ref, week) => ShoppingListNotifier(ref.read(menuServiceProvider), week),
);
// ── Diary notifier ────────────────────────────────────────────
class DiaryNotifier extends StateNotifier<AsyncValue<List<DiaryEntry>>> {
final MenuService _service;
final String _date;
DiaryNotifier(this._service, this._date) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getDiary(_date));
}
Future<void> add(Map<String, dynamic> body) async {
await _service.createDiaryEntry(body);
await load();
}
Future<void> remove(String id) async {
final prev = state;
state = state.whenData((l) => l.where((e) => e.id != id).toList());
try {
await _service.deleteDiaryEntry(id);
} catch (_) {
state = prev;
}
}
}
final diaryProvider =
StateNotifierProvider.family<DiaryNotifier, AsyncValue<List<DiaryEntry>>, String>(
(ref, date) => DiaryNotifier(ref.read(menuServiceProvider), date),
);

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

View File

@@ -0,0 +1,85 @@
import '../../core/api/api_client.dart';
import '../../shared/models/diary_entry.dart';
import '../../shared/models/menu.dart';
import '../../shared/models/shopping_item.dart';
class MenuService {
final ApiClient _client;
MenuService(this._client);
// ── Menu ──────────────────────────────────────────────────
Future<MenuPlan?> getMenu({String? week}) async {
final params = <String, dynamic>{};
if (week != null) params['week'] = week;
final data = await _client.get('/menu', params: params);
// Backend returns {"week_start": "...", "days": null} when no plan exists.
if (data['id'] == null) return null;
return MenuPlan.fromJson(data);
}
Future<MenuPlan> generateMenu({String? week}) async {
final body = <String, dynamic>{};
if (week != null) body['week'] = week;
final data = await _client.post('/ai/generate-menu', data: body);
return MenuPlan.fromJson(data);
}
Future<void> updateMenuItem(String itemId, String recipeId) async {
await _client.put('/menu/items/$itemId', data: {'recipe_id': recipeId});
}
Future<void> deleteMenuItem(String itemId) async {
await _client.deleteVoid('/menu/items/$itemId');
}
// ── Shopping list ─────────────────────────────────────────
Future<List<ShoppingItem>> getShoppingList({String? week}) async {
final params = <String, dynamic>{};
if (week != null) params['week'] = week;
final data = await _client.getList('/shopping-list', params: params);
return data
.map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<ShoppingItem>> generateShoppingList({String? week}) async {
final body = <String, dynamic>{};
if (week != null) body['week'] = week;
final data = await _client.postList('/shopping-list/generate', data: body);
return data
.map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> toggleShoppingItem(int index, bool checked,
{String? week}) async {
final params = <String, dynamic>{};
if (week != null) params['week'] = week;
await _client.patch(
'/shopping-list/items/$index/check',
data: {'checked': checked},
params: params,
);
}
// ── Diary ─────────────────────────────────────────────────
Future<List<DiaryEntry>> getDiary(String date) async {
final data = await _client.getList('/diary', params: {'date': date});
return data
.map((e) => DiaryEntry.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<DiaryEntry> createDiaryEntry(Map<String, dynamic> body) async {
final data = await _client.post('/diary', data: body);
return DiaryEntry.fromJson(data);
}
Future<void> deleteDiaryEntry(String id) async {
await _client.deleteVoid('/diary/$id');
}
}

View File

@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/models/shopping_item.dart';
import 'menu_provider.dart';
class ShoppingListScreen extends ConsumerWidget {
final String week;
const ShoppingListScreen({super.key, required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(shoppingListProvider(week));
return Scaffold(
appBar: AppBar(
title: const Text('Список покупок'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Пересоздать список',
onPressed: () =>
ref.read(shoppingListProvider(week).notifier).regenerate(),
),
],
),
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => 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: () =>
ref.read(shoppingListProvider(week).notifier).load(),
child: const Text('Повторить'),
),
],
),
),
data: (items) => items.isEmpty
? _EmptyState(week: week)
: _ShoppingList(items: items, week: week),
),
);
}
}
class _ShoppingList extends ConsumerWidget {
final List<ShoppingItem> items;
final String week;
const _ShoppingList({required this.items, required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
// Group items by category.
final Map<String, List<(int, ShoppingItem)>> grouped = {};
for (var i = 0; i < items.length; i++) {
final item = items[i];
final cat = _categoryLabel(item.category);
grouped.putIfAbsent(cat, () => []).add((i, item));
}
final uncheckedCount = items.where((e) => !e.checked).length;
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 80),
children: [
for (final entry in grouped.entries) ...[
_SectionHeader(label: entry.key),
for (final (index, item) in entry.value)
_ShoppingTile(
item: item,
index: index,
week: week,
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
Text(
'Осталось купить: $uncheckedCount',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
String _categoryLabel(String category) {
switch (category) {
case 'meat':
return 'Мясо';
case 'dairy':
return 'Молочное';
case 'vegetable':
return 'Овощи';
case 'fruit':
return 'Фрукты';
case 'grain':
return 'Крупы и злаки';
case 'seafood':
return 'Морепродукты';
case 'condiment':
return 'Специи и соусы';
default:
return 'Прочее';
}
}
}
class _SectionHeader extends StatelessWidget {
final String label;
const _SectionHeader({required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _ShoppingTile extends ConsumerWidget {
final ShoppingItem item;
final int index;
final String week;
const _ShoppingTile({
required this.item,
required this.index,
required this.week,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final amountStr = item.amount == item.amount.roundToDouble()
? item.amount.toInt().toString()
: item.amount.toStringAsFixed(1);
return ListTile(
leading: Checkbox(
value: item.checked,
onChanged: (checked) {
ref
.read(shoppingListProvider(week).notifier)
.toggle(index, checked ?? false);
},
),
title: Text(
item.name,
style: TextStyle(
decoration: item.checked ? TextDecoration.lineThrough : null,
color: item.checked ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: item.inStock > 0
? Text(
'${item.inStock.toStringAsFixed(0)} ${item.unit} есть дома',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.green,
),
)
: null,
trailing: Text(
'$amountStr ${item.unit}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
);
}
}
class _EmptyState extends ConsumerWidget {
final String week;
const _EmptyState({required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.shopping_cart_outlined,
size: 72,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
const Text('Список покупок пуст'),
const SizedBox(height: 8),
const Text(
'Сначала сгенерируйте меню на неделю, затем список покупок сформируется автоматически',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () =>
ref.read(shoppingListProvider(week).notifier).regenerate(),
icon: const Icon(Icons.refresh),
label: const Text('Сформировать список'),
),
],
),
),
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

View File

@@ -0,0 +1,56 @@
class DiaryEntry {
final String id;
final String date;
final String mealType;
final String name;
final double portions;
final double? calories;
final double? proteinG;
final double? fatG;
final double? carbsG;
final String source;
final String? recipeId;
const DiaryEntry({
required this.id,
required this.date,
required this.mealType,
required this.name,
required this.portions,
this.calories,
this.proteinG,
this.fatG,
this.carbsG,
required this.source,
this.recipeId,
});
factory DiaryEntry.fromJson(Map<String, dynamic> json) {
return DiaryEntry(
id: json['id'] as String? ?? '',
date: json['date'] as String? ?? '',
mealType: json['meal_type'] as String? ?? '',
name: json['name'] as String? ?? '',
portions: (json['portions'] as num?)?.toDouble() ?? 1,
calories: (json['calories'] as num?)?.toDouble(),
proteinG: (json['protein_g'] as num?)?.toDouble(),
fatG: (json['fat_g'] as num?)?.toDouble(),
carbsG: (json['carbs_g'] as num?)?.toDouble(),
source: json['source'] as String? ?? 'manual',
recipeId: json['recipe_id'] as String?,
);
}
String get mealLabel {
switch (mealType) {
case 'breakfast':
return 'Завтрак';
case 'lunch':
return 'Обед';
case 'dinner':
return 'Ужин';
default:
return mealType;
}
}
}

View File

@@ -0,0 +1,151 @@
import 'recipe.dart';
class MenuPlan {
final String id;
final String weekStart;
final List<MenuDay> days;
const MenuPlan({
required this.id,
required this.weekStart,
required this.days,
});
factory MenuPlan.fromJson(Map<String, dynamic> json) {
return MenuPlan(
id: json['id'] as String? ?? '',
weekStart: json['week_start'] as String? ?? '',
days: (json['days'] as List<dynamic>? ?? [])
.map((e) => MenuDay.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
class MenuDay {
final int day;
final String date;
final List<MealSlot> meals;
final double totalCalories;
const MenuDay({
required this.day,
required this.date,
required this.meals,
required this.totalCalories,
});
factory MenuDay.fromJson(Map<String, dynamic> json) {
return MenuDay(
day: json['day'] as int? ?? 0,
date: json['date'] as String? ?? '',
meals: (json['meals'] as List<dynamic>? ?? [])
.map((e) => MealSlot.fromJson(e as Map<String, dynamic>))
.toList(),
totalCalories: (json['total_calories'] as num?)?.toDouble() ?? 0,
);
}
// Localized day name.
String get dayName {
const names = [
'Понедельник',
'Вторник',
'Среда',
'Четверг',
'Пятница',
'Суббота',
'Воскресенье',
];
final i = day - 1;
return (i >= 0 && i < names.length) ? names[i] : 'День $day';
}
// Short date label like "16 фев".
String get shortDate {
try {
final dt = DateTime.parse(date);
const months = [
'янв', 'фев', 'мар', 'апр', 'май', 'июн',
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
];
return '${dt.day} ${months[dt.month - 1]}';
} catch (_) {
return date;
}
}
}
class MealSlot {
final String id;
final String mealType;
final MenuRecipe? recipe;
const MealSlot({
required this.id,
required this.mealType,
this.recipe,
});
factory MealSlot.fromJson(Map<String, dynamic> json) {
return MealSlot(
id: json['id'] as String? ?? '',
mealType: json['meal_type'] as String? ?? '',
recipe: json['recipe'] != null
? MenuRecipe.fromJson(json['recipe'] as Map<String, dynamic>)
: null,
);
}
String get mealLabel {
switch (mealType) {
case 'breakfast':
return 'Завтрак';
case 'lunch':
return 'Обед';
case 'dinner':
return 'Ужин';
default:
return mealType;
}
}
String get mealEmoji {
switch (mealType) {
case 'breakfast':
return '🌅';
case 'lunch':
return '☀️';
case 'dinner':
return '🌙';
default:
return '🍽️';
}
}
}
class MenuRecipe {
final String id;
final String title;
final String imageUrl;
final NutritionInfo? nutrition;
const MenuRecipe({
required this.id,
required this.title,
required this.imageUrl,
this.nutrition,
});
factory MenuRecipe.fromJson(Map<String, dynamic> json) {
return MenuRecipe(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
imageUrl: json['image_url'] as String? ?? '',
nutrition: json['nutrition_per_serving'] != null
? NutritionInfo.fromJson(
json['nutrition_per_serving'] as Map<String, dynamic>)
: null,
);
}
}

View File

@@ -0,0 +1,46 @@
class ShoppingItem {
final String name;
final String category;
final double amount;
final String unit;
final bool checked;
final double inStock;
const ShoppingItem({
required this.name,
required this.category,
required this.amount,
required this.unit,
required this.checked,
required this.inStock,
});
factory ShoppingItem.fromJson(Map<String, dynamic> json) {
return ShoppingItem(
name: json['name'] as String? ?? '',
category: json['category'] as String? ?? 'other',
amount: (json['amount'] as num?)?.toDouble() ?? 0,
unit: json['unit'] as String? ?? '',
checked: json['checked'] as bool? ?? false,
inStock: (json['in_stock'] as num?)?.toDouble() ?? 0,
);
}
Map<String, dynamic> toJson() => {
'name': name,
'category': category,
'amount': amount,
'unit': unit,
'checked': checked,
'in_stock': inStock,
};
ShoppingItem copyWith({bool? checked}) => ShoppingItem(
name: name,
category: category,
amount: amount,
unit: unit,
checked: checked ?? this.checked,
inStock: inStock,
);
}