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>
290 lines
8.7 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|