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:
151
client/lib/shared/models/menu.dart
Normal file
151
client/lib/shared/models/menu.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user