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:
289
client/lib/features/menu/diary_screen.dart
Normal file
289
client/lib/features/menu/diary_screen.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
147
client/lib/features/menu/menu_provider.dart
Normal file
147
client/lib/features/menu/menu_provider.dart
Normal 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),
|
||||
);
|
||||
@@ -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('Повторить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
85
client/lib/features/menu/menu_service.dart
Normal file
85
client/lib/features/menu/menu_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
247
client/lib/features/menu/shopping_list_screen.dart
Normal file
247
client/lib/features/menu/shopping_list_screen.dart
Normal 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('Сформировать список'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user