Files
food-ai/client/lib/features/menu/diary_screen.dart
dbastrikin ea8e207a45 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>
2026-02-22 12:00:25 +02:00

290 lines
8.7 KiB
Dart

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);
}
}
}