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