From 9580bff54e6cbcf9d4d3b752ce0cc1f93272de89 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 22 Mar 2026 12:10:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20flexible=20meal=20planning=20wizard=20?= =?UTF-8?q?=E2=80=94=20plan=201=20meal,=201=20day,=20several=20days,=20or?= =?UTF-8?q?=20a=20week?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - migration 005: expand menu_items.meal_type CHECK to all 6 types (second_breakfast, afternoon_snack, snack) - ai/types.go: add Days and MealTypes to MenuRequest for partial generation - openai/menu.go: parametrize GenerateMenu — use requested meal types and day count; add caloric fractions for all 6 meal types - menu/repository.go: add UpsertItemsInTx for partial upsert (preserves existing slots); fix meal_type sort order in GetByWeek - menu/handler.go: add dates+meal_types path to POST /ai/generate-menu; extract fetchImages/saveRecipes helpers; returns {"plans":[...]} for dates mode; backward-compatible with week mode Client: - PlanMenuSheet: bottom sheet with 4 planning horizon options - PlanDatePickerSheet: adaptive sheet with date strip (single day/meal) or custom CalendarRangePicker (multi-day/week); sliding 7-day window for week mode - menu_service.dart: add generateForDates - menu_provider.dart: add PlanMenuService (generates + invalidates week providers), lastPlannedDateProvider - home_screen.dart: add _PlanMenuButton card below quick actions; opens planning wizard - l10n: 16 new keys for planning UI across all 12 languages Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/adapters/ai/types.go | 8 +- backend/internal/adapters/openai/menu.go | 95 +-- backend/internal/domain/menu/handler.go | 281 ++++++--- backend/internal/domain/menu/repository.go | 47 +- backend/migrations/005_partial_menu.sql | 12 + client/lib/features/home/home_screen.dart | 66 ++ client/lib/features/menu/menu_provider.dart | 51 ++ client/lib/features/menu/menu_service.dart | 16 + .../features/menu/plan_date_picker_sheet.dart | 584 ++++++++++++++++++ client/lib/features/menu/plan_menu_sheet.dart | 101 +++ client/lib/l10n/app_ar.arb | 19 +- client/lib/l10n/app_de.arb | 19 +- client/lib/l10n/app_en.arb | 19 +- client/lib/l10n/app_es.arb | 19 +- client/lib/l10n/app_fr.arb | 19 +- client/lib/l10n/app_hi.arb | 19 +- client/lib/l10n/app_it.arb | 19 +- client/lib/l10n/app_ja.arb | 19 +- client/lib/l10n/app_ko.arb | 19 +- client/lib/l10n/app_localizations.dart | 96 +++ client/lib/l10n/app_localizations_ar.dart | 48 ++ client/lib/l10n/app_localizations_de.dart | 48 ++ client/lib/l10n/app_localizations_en.dart | 48 ++ client/lib/l10n/app_localizations_es.dart | 48 ++ client/lib/l10n/app_localizations_fr.dart | 48 ++ client/lib/l10n/app_localizations_hi.dart | 48 ++ client/lib/l10n/app_localizations_it.dart | 48 ++ client/lib/l10n/app_localizations_ja.dart | 48 ++ client/lib/l10n/app_localizations_ko.dart | 48 ++ client/lib/l10n/app_localizations_pt.dart | 48 ++ client/lib/l10n/app_localizations_ru.dart | 48 ++ client/lib/l10n/app_localizations_zh.dart | 48 ++ client/lib/l10n/app_pt.arb | 19 +- client/lib/l10n/app_ru.arb | 19 +- client/lib/l10n/app_zh.arb | 19 +- 35 files changed, 2025 insertions(+), 136 deletions(-) create mode 100644 backend/migrations/005_partial_menu.sql create mode 100644 client/lib/features/menu/plan_date_picker_sheet.dart create mode 100644 client/lib/features/menu/plan_menu_sheet.dart diff --git a/backend/internal/adapters/ai/types.go b/backend/internal/adapters/ai/types.go index 0e5fd65..3b4c506 100644 --- a/backend/internal/adapters/ai/types.go +++ b/backend/internal/adapters/ai/types.go @@ -51,7 +51,7 @@ type NutritionInfo struct { Approximate bool `json:"approximate"` } -// MenuRequest contains parameters for weekly menu generation. +// MenuRequest contains parameters for menu generation. type MenuRequest struct { UserGoal string DailyCalories int @@ -59,6 +59,12 @@ type MenuRequest struct { CuisinePrefs []string AvailableProducts []string Lang string // ISO 639-1 target language code, e.g. "en", "ru" + // Days specifies which day-of-week slots (1=Monday…7=Sunday) to generate. + // When nil, all 7 days are generated. + Days []int + // MealTypes restricts generation to the listed meal types. + // When nil, defaults to ["breakfast","lunch","dinner"]. + MealTypes []string } // DayPlan is the AI-generated plan for a single day. diff --git a/backend/internal/adapters/openai/menu.go b/backend/internal/adapters/openai/menu.go index 635d882..3e0fb52 100644 --- a/backend/internal/adapters/openai/menu.go +++ b/backend/internal/adapters/openai/menu.go @@ -8,72 +8,91 @@ import ( "github.com/food-ai/backend/internal/adapters/ai" ) -// GenerateMenu produces a 7-day × 3-meal plan by issuing three parallel -// GenerateRecipes calls (one per meal type). This avoids token-limit errors -// that arise from requesting 21 full recipes in a single prompt. +// caloricFractions maps each meal type to its share of the daily calorie budget. +var caloricFractions = map[string]float64{ + "breakfast": 0.25, + "second_breakfast": 0.10, + "lunch": 0.35, + "afternoon_snack": 0.10, + "dinner": 0.20, + "snack": 0.10, +} + +// defaultMealTypes is used when MenuRequest.MealTypes is nil. +var defaultMealTypes = []string{"breakfast", "lunch", "dinner"} + +// GenerateMenu produces a meal plan by issuing one parallel GenerateRecipes call per meal +// type. The number of recipes per type equals the number of days requested. func (c *Client) GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error) { - type mealSlot struct { - mealType string - fraction float64 // share of daily calories + mealTypes := req.MealTypes + if len(mealTypes) == 0 { + mealTypes = defaultMealTypes } - slots := []mealSlot{ - {"breakfast", 0.25}, - {"lunch", 0.40}, - {"dinner", 0.35}, + days := req.Days + if len(days) == 0 { + days = make([]int, 7) + for dayIndex := range days { + days[dayIndex] = dayIndex + 1 + } } + recipeCount := len(days) type mealResult struct { recipes []ai.Recipe err error } - results := make([]mealResult, len(slots)) + results := make([]mealResult, len(mealTypes)) var wg sync.WaitGroup - for i, slot := range slots { + for slotIndex, mealType := range mealTypes { wg.Add(1) - go func(idx int, mealType string, fraction float64) { + go func(idx int, mealTypeName string) { defer wg.Done() - // Scale daily calories to what this meal should contribute. - mealCal := int(float64(req.DailyCalories) * fraction) - r, err := c.GenerateRecipes(ctx, ai.RecipeRequest{ + fraction, ok := caloricFractions[mealTypeName] + if !ok { + fraction = 0.15 + } + mealCalories := int(float64(req.DailyCalories) * fraction) + recipes, generateError := c.GenerateRecipes(ctx, ai.RecipeRequest{ UserGoal: req.UserGoal, - DailyCalories: mealCal * 3, // prompt divides by 3 internally + DailyCalories: mealCalories * recipeCount, Restrictions: req.Restrictions, CuisinePrefs: req.CuisinePrefs, - Count: 7, + Count: recipeCount, AvailableProducts: req.AvailableProducts, Lang: req.Lang, }) - results[idx] = mealResult{r, err} - }(i, slot.mealType, slot.fraction) + results[idx] = mealResult{recipes, generateError} + }(slotIndex, mealType) } wg.Wait() - for i, res := range results { - if res.err != nil { - return nil, fmt.Errorf("generate %s: %w", slots[i].mealType, res.err) + for slotIndex, result := range results { + if result.err != nil { + return nil, fmt.Errorf("generate %s: %w", mealTypes[slotIndex], result.err) } - if len(res.recipes) == 0 { - return nil, fmt.Errorf("no %s recipes returned", slots[i].mealType) + if len(result.recipes) == 0 { + return nil, fmt.Errorf("no %s recipes returned", mealTypes[slotIndex]) } - // Pad to exactly 7 by repeating the last recipe. - for len(results[i].recipes) < 7 { - results[i].recipes = append(results[i].recipes, results[i].recipes[len(results[i].recipes)-1]) + // Pad to exactly recipeCount by repeating the last recipe. + for len(results[slotIndex].recipes) < recipeCount { + last := results[slotIndex].recipes[len(results[slotIndex].recipes)-1] + results[slotIndex].recipes = append(results[slotIndex].recipes, last) } } - days := make([]ai.DayPlan, 7) - for day := range 7 { - days[day] = ai.DayPlan{ - Day: day + 1, - Meals: []ai.MealEntry{ - {MealType: slots[0].mealType, Recipe: results[0].recipes[day]}, - {MealType: slots[1].mealType, Recipe: results[1].recipes[day]}, - {MealType: slots[2].mealType, Recipe: results[2].recipes[day]}, - }, + dayPlans := make([]ai.DayPlan, recipeCount) + for dayIndex, dayOfWeek := range days { + meals := make([]ai.MealEntry, len(mealTypes)) + for slotIndex, mealType := range mealTypes { + meals[slotIndex] = ai.MealEntry{ + MealType: mealType, + Recipe: results[slotIndex].recipes[dayIndex], + } } + dayPlans[dayIndex] = ai.DayPlan{Day: dayOfWeek, Meals: meals} } - return days, nil + return dayPlans, nil } diff --git a/backend/internal/domain/menu/handler.go b/backend/internal/domain/menu/handler.go index 7c760a1..eb43df6 100644 --- a/backend/internal/domain/menu/handler.go +++ b/backend/internal/domain/menu/handler.go @@ -108,6 +108,11 @@ func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) { } // GenerateMenu handles POST /ai/generate-menu +// +// Two modes: +// - dates mode (body.Dates non-empty): generate for specific dates and meal types, +// upsert only those slots; returns {"plans":[...]}. +// - week mode (existing): generate full 7-day week; returns a single MenuPlan. func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { @@ -116,124 +121,232 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { } var body struct { - Week string `json:"week"` // optional, defaults to current week + Week string `json:"week"` // optional, defaults to current week + Dates []string `json:"dates"` // YYYY-MM-DD; triggers partial generation + MealTypes []string `json:"meal_types"` // overrides user preference when set } _ = json.NewDecoder(r.Body).Decode(&body) - weekStart, err := ResolveWeekStart(body.Week) - if err != nil { - writeError(w, r, http.StatusBadRequest, "invalid week parameter") + // Load user profile (needed for both paths). + u, loadError := h.userLoader.GetByID(r.Context(), userID) + if loadError != nil { + slog.ErrorContext(r.Context(), "load user for menu generation", "err", loadError) + writeError(w, r, http.StatusInternalServerError, "failed to load user profile") return } - // Load user profile. - u, err := h.userLoader.GetByID(r.Context(), userID) - if err != nil { - slog.ErrorContext(r.Context(), "load user for menu generation", "err", err) - writeError(w, r, http.StatusInternalServerError, "failed to load user profile") + if len(body.Dates) > 0 { + h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes) + return + } + + // ── Full-week path (existing behaviour) ────────────────────────────── + + weekStart, weekError := ResolveWeekStart(body.Week) + if weekError != nil { + writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } menuReq := buildMenuRequest(u, locale.FromContext(r.Context())) - // Attach pantry products. - if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { + if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil { menuReq.AvailableProducts = products } - // Generate 7-day plan via Gemini. - days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq) - if err != nil { - slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", err) + days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq) + if generateError != nil { + slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", generateError) writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again") return } - // Fetch Pexels images for all 21 recipes in parallel. - type indexedRecipe struct { - day int - meal int - imageURL string - } - imageResults := make([]indexedRecipe, 0, len(days)*3) - var mu sync.Mutex - var wg sync.WaitGroup + h.fetchImages(r.Context(), days) - for di, day := range days { - for mi := range day.Meals { - wg.Add(1) - go func(di, mi int, query string) { - defer wg.Done() - url, err := h.pexels.SearchPhoto(r.Context(), query) - if err != nil { - slog.WarnContext(r.Context(), "pexels search failed", "query", query, "err", err) - } - mu.Lock() - imageResults = append(imageResults, indexedRecipe{di, mi, url}) - mu.Unlock() - }(di, mi, day.Meals[mi].Recipe.ImageQuery) - } - } - wg.Wait() - - for _, res := range imageResults { - days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL + planItems, saveError := h.saveRecipes(r.Context(), days) + if saveError != nil { + writeError(w, r, http.StatusInternalServerError, "failed to save recipes") + return } - // Persist all 21 recipes as dish+recipe rows. - type savedRef struct { - day int - meal int - recipeID string - } - refs := make([]savedRef, 0, len(days)*3) - for di, day := range days { - for mi, meal := range day.Meals { - recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe)) - if err != nil { - slog.ErrorContext(r.Context(), "save recipe for menu", "title", meal.Recipe.Title, "err", err) - writeError(w, r, http.StatusInternalServerError, "failed to save recipes") - return - } - refs = append(refs, savedRef{di, mi, recipeID}) - } - } - - // Build PlanItems list in day/meal order. - planItems := make([]PlanItem, 0, 21) - for _, ref := range refs { - planItems = append(planItems, PlanItem{ - DayOfWeek: days[ref.day].Day, - MealType: days[ref.day].Meals[ref.meal].MealType, - RecipeID: ref.recipeID, - }) - } - - // Persist in a single transaction. - planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) - if err != nil { - slog.ErrorContext(r.Context(), "save menu plan", "err", err) + planID, txError := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) + if txError != nil { + slog.ErrorContext(r.Context(), "save menu plan", "err", txError) writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") return } - // Auto-generate shopping list. - if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil { - if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil { - slog.WarnContext(r.Context(), "auto-generate shopping list", "err", err) + if shoppingItems, shoppingError := h.buildShoppingList(r.Context(), planID); shoppingError == nil { + if upsertError := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); upsertError != nil { + slog.WarnContext(r.Context(), "auto-generate shopping list", "err", upsertError) } } - // Return the freshly saved plan. - plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) - if err != nil || plan == nil { - slog.ErrorContext(r.Context(), "load generated menu", "err", err, "plan_nil", plan == nil) + plan, loadPlanError := h.repo.GetByWeek(r.Context(), userID, weekStart) + if loadPlanError != nil || plan == nil { + slog.ErrorContext(r.Context(), "load generated menu", "err", loadPlanError, "plan_nil", plan == nil) writeError(w, r, http.StatusInternalServerError, "failed to load generated menu") return } writeJSON(w, http.StatusOK, plan) } +// generateForDates handles partial menu generation for specific dates and meal types. +// It groups dates by ISO week, generates only the requested slots, upserts them +// without touching other existing slots, and returns {"plans":[...]}. +func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userID string, u *user.User, dates, requestedMealTypes []string) { + mealTypes := requestedMealTypes + if len(mealTypes) == 0 { + // Fall back to user's preferred meal types. + var prefs struct { + MealTypes []string `json:"meal_types"` + } + if len(u.Preferences) > 0 { + _ = json.Unmarshal(u.Preferences, &prefs) + } + mealTypes = prefs.MealTypes + } + if len(mealTypes) == 0 { + mealTypes = []string{"breakfast", "lunch", "dinner"} + } + + menuReq := buildMenuRequest(u, locale.FromContext(r.Context())) + menuReq.MealTypes = mealTypes + if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil { + menuReq.AvailableProducts = products + } + + weekGroups := groupDatesByWeek(dates) + var plans []*MenuPlan + + for weekStart, datesInWeek := range weekGroups { + menuReq.Days = datesToDOW(datesInWeek) + + days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq) + if generateError != nil { + slog.ErrorContext(r.Context(), "generate menu for dates", "week", weekStart, "err", generateError) + writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again") + return + } + + h.fetchImages(r.Context(), days) + + planItems, saveError := h.saveRecipes(r.Context(), days) + if saveError != nil { + writeError(w, r, http.StatusInternalServerError, "failed to save recipes") + return + } + + planID, upsertError := h.repo.UpsertItemsInTx(r.Context(), userID, weekStart, planItems) + if upsertError != nil { + slog.ErrorContext(r.Context(), "upsert menu items", "week", weekStart, "err", upsertError) + writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") + return + } + + if shoppingItems, shoppingError := h.buildShoppingList(r.Context(), planID); shoppingError == nil { + if upsertShoppingError := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); upsertShoppingError != nil { + slog.WarnContext(r.Context(), "auto-generate shopping list", "err", upsertShoppingError) + } + } + + plan, loadError := h.repo.GetByWeek(r.Context(), userID, weekStart) + if loadError != nil || plan == nil { + slog.ErrorContext(r.Context(), "load generated plan", "week", weekStart, "err", loadError) + writeError(w, r, http.StatusInternalServerError, "failed to load generated menu") + return + } + plans = append(plans, plan) + } + + writeJSON(w, http.StatusOK, map[string]any{"plans": plans}) +} + +// fetchImages fetches Pexels images for all meals in parallel, mutating days in place. +func (h *Handler) fetchImages(ctx context.Context, days []ai.DayPlan) { + type indexedResult struct { + day int + meal int + imageURL string + } + imageResults := make([]indexedResult, 0, len(days)*6) + var mu sync.Mutex + var wg sync.WaitGroup + + for dayIndex, day := range days { + for mealIndex := range day.Meals { + wg.Add(1) + go func(di, mi int, query string) { + defer wg.Done() + url, fetchError := h.pexels.SearchPhoto(ctx, query) + if fetchError != nil { + slog.WarnContext(ctx, "pexels search failed", "query", query, "err", fetchError) + } + mu.Lock() + imageResults = append(imageResults, indexedResult{di, mi, url}) + mu.Unlock() + }(dayIndex, mealIndex, day.Meals[mealIndex].Recipe.ImageQuery) + } + } + wg.Wait() + + for _, result := range imageResults { + days[result.day].Meals[result.meal].Recipe.ImageURL = result.imageURL + } +} + +// saveRecipes persists all recipes as dish+recipe rows and returns a PlanItem list. +func (h *Handler) saveRecipes(ctx context.Context, days []ai.DayPlan) ([]PlanItem, error) { + planItems := make([]PlanItem, 0, len(days)*6) + for _, day := range days { + for _, meal := range day.Meals { + recipeID, createError := h.recipeSaver.Create(ctx, recipeToCreateRequest(meal.Recipe)) + if createError != nil { + slog.ErrorContext(ctx, "save recipe for menu", "title", meal.Recipe.Title, "err", createError) + return nil, createError + } + planItems = append(planItems, PlanItem{ + DayOfWeek: day.Day, + MealType: meal.MealType, + RecipeID: recipeID, + }) + } + } + return planItems, nil +} + +// groupDatesByWeek groups YYYY-MM-DD date strings by their ISO week's Monday date. +func groupDatesByWeek(dates []string) map[string][]string { + result := map[string][]string{} + for _, date := range dates { + t, parseError := time.Parse("2006-01-02", date) + if parseError != nil { + continue + } + year, week := t.ISOWeek() + weekStart := mondayOfISOWeek(year, week).Format("2006-01-02") + result[weekStart] = append(result[weekStart], date) + } + return result +} + +// datesToDOW converts date strings to ISO day-of-week values (1=Monday, 7=Sunday). +func datesToDOW(dates []string) []int { + dows := make([]int, 0, len(dates)) + for _, date := range dates { + t, parseError := time.Parse("2006-01-02", date) + if parseError != nil { + continue + } + weekday := int(t.Weekday()) + if weekday == 0 { + weekday = 7 // Go's Sunday=0 → ISO Sunday=7 + } + dows = append(dows, weekday) + } + return dows +} + // UpdateMenuItem handles PUT /menu/items/{id} func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) diff --git a/backend/internal/domain/menu/repository.go b/backend/internal/domain/menu/repository.go index 29d3598..c40b23f 100644 --- a/backend/internal/domain/menu/repository.go +++ b/backend/internal/domain/menu/repository.go @@ -47,7 +47,15 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (* LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 WHERE mp.user_id = $1 AND mp.week_start::text = $2 ORDER BY mi.day_of_week, - CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END` + CASE mi.meal_type + WHEN 'breakfast' THEN 1 + WHEN 'second_breakfast' THEN 2 + WHEN 'lunch' THEN 3 + WHEN 'afternoon_snack' THEN 4 + WHEN 'dinner' THEN 5 + WHEN 'snack' THEN 6 + ELSE 7 + END` rows, err := r.pool.Query(ctx, q, userID, weekStart, lang) if err != nil { @@ -163,6 +171,43 @@ func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string, return planID, nil } +// UpsertItemsInTx creates (or retrieves) the menu_plan for the given week and +// upserts only the specified meal slots, leaving all other existing slots untouched. +func (r *Repository) UpsertItemsInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) { + transaction, beginError := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if beginError != nil { + return "", fmt.Errorf("begin tx: %w", beginError) + } + defer transaction.Rollback(ctx) //nolint:errcheck + + var planID string + upsertError := transaction.QueryRow(ctx, ` + INSERT INTO menu_plans (user_id, week_start) + VALUES ($1, $2::date) + ON CONFLICT (user_id, week_start) DO UPDATE SET created_at = now() + RETURNING id`, userID, weekStart).Scan(&planID) + if upsertError != nil { + return "", fmt.Errorf("upsert menu_plan: %w", upsertError) + } + + for _, item := range items { + if _, insertError := transaction.Exec(ctx, ` + INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (menu_plan_id, day_of_week, meal_type) + DO UPDATE SET recipe_id = EXCLUDED.recipe_id`, + planID, item.DayOfWeek, item.MealType, item.RecipeID, + ); insertError != nil { + return "", fmt.Errorf("upsert menu item day=%d meal=%s: %w", item.DayOfWeek, item.MealType, insertError) + } + } + + if commitError := transaction.Commit(ctx); commitError != nil { + return "", fmt.Errorf("commit tx: %w", commitError) + } + return planID, nil +} + // UpdateItem replaces the recipe in a menu slot. func (r *Repository) UpdateItem(ctx context.Context, itemID, userID, recipeID string) error { tag, err := r.pool.Exec(ctx, ` diff --git a/backend/migrations/005_partial_menu.sql b/backend/migrations/005_partial_menu.sql new file mode 100644 index 0000000..00ac607 --- /dev/null +++ b/backend/migrations/005_partial_menu.sql @@ -0,0 +1,12 @@ +-- +goose Up + +-- Expand the meal_type check to cover all six meal types used by the client. +ALTER TABLE menu_items DROP CONSTRAINT menu_items_meal_type_check; +ALTER TABLE menu_items ADD CONSTRAINT menu_items_meal_type_check + CHECK (meal_type IN ('breakfast','second_breakfast','lunch','afternoon_snack','dinner','snack')); + +-- +goose Down + +ALTER TABLE menu_items DROP CONSTRAINT menu_items_meal_type_check; +ALTER TABLE menu_items ADD CONSTRAINT menu_items_meal_type_check + CHECK (meal_type IN ('breakfast','lunch','dinner')); diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index 5de241f..f6b6382 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -16,6 +16,8 @@ import '../../shared/models/meal_type.dart'; import '../../shared/models/menu.dart'; import '../diary/food_search_sheet.dart'; import '../menu/menu_provider.dart'; +import '../menu/plan_date_picker_sheet.dart'; +import '../menu/plan_menu_sheet.dart'; import '../profile/profile_provider.dart'; import '../scan/dish_result_screen.dart'; import '../scan/recognition_service.dart'; @@ -126,6 +128,8 @@ class HomeScreen extends ConsumerWidget { ], const SizedBox(height: 16), _QuickActionsRow(), + const SizedBox(height: 8), + _PlanMenuButton(), if (!isFutureDate && recommendations.isNotEmpty) ...[ const SizedBox(height: 20), _SectionTitle(l10n.recommendCook), @@ -1641,6 +1645,68 @@ class _ActionButton extends StatelessWidget { } } +// ── Plan menu button ────────────────────────────────────────── + +class _PlanMenuButton extends ConsumerWidget { + const _PlanMenuButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Card( + child: InkWell( + onTap: () => _openPlanSheet(context, ref), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(Icons.edit_calendar_outlined, + color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.planMenuButton, + style: theme.textTheme.titleSmall, + ), + ), + Icon(Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant), + ], + ), + ), + ), + ); + } + + void _openPlanSheet(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => PlanMenuSheet( + onModeSelected: (mode) { + final lastPlanned = ref.read(lastPlannedDateProvider); + final defaultStart = lastPlanned != null + ? lastPlanned.add(const Duration(days: 1)) + : DateTime.now().add(const Duration(days: 1)); + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => PlanDatePickerSheet( + mode: mode, + defaultStart: defaultStart, + ), + ); + }, + ), + ); + } +} + // ── Section title ───────────────────────────────────────────── class _SectionTitle extends StatelessWidget { diff --git a/client/lib/features/menu/menu_provider.dart b/client/lib/features/menu/menu_provider.dart index 4058212..2c0c19c 100644 --- a/client/lib/features/menu/menu_provider.dart +++ b/client/lib/features/menu/menu_provider.dart @@ -151,3 +151,54 @@ final diaryProvider = StateNotifierProvider.family>, String>( (ref, date) => DiaryNotifier(ref.read(menuServiceProvider), date), ); + +// ── Plan menu helpers ───────────────────────────────────────── + +/// The latest future date (among cached menus for the current and next two weeks) +/// that already has at least one planned meal slot. Returns null when no future +/// meals are planned. +final lastPlannedDateProvider = Provider((ref) { + final today = DateTime.now(); + DateTime? latestDate; + for (var offsetDays = 0; offsetDays <= 14; offsetDays += 7) { + final weekDate = today.add(Duration(days: offsetDays)); + final plan = ref.watch(menuProvider(isoWeekString(weekDate))).valueOrNull; + if (plan == null) continue; + for (final day in plan.days) { + final dayDate = DateTime.parse(day.date); + if (dayDate.isAfter(today) && day.meals.isNotEmpty) { + if (latestDate == null || dayDate.isAfter(latestDate)) { + latestDate = dayDate; + } + } + } + } + return latestDate; +}); + +/// Service for planning meals across specific dates. +/// Calls the API and then invalidates the affected week providers so that +/// [plannedMealsProvider] picks up the new slots automatically. +class PlanMenuService { + final Ref _ref; + + const PlanMenuService(this._ref); + + Future generateForDates({ + required List dates, + required List mealTypes, + }) async { + final menuService = _ref.read(menuServiceProvider); + final plans = await menuService.generateForDates( + dates: dates, + mealTypes: mealTypes, + ); + for (final plan in plans) { + _ref.invalidate(menuProvider(isoWeekString(DateTime.parse(plan.weekStart)))); + } + } +} + +final planMenuServiceProvider = Provider((ref) { + return PlanMenuService(ref); +}); diff --git a/client/lib/features/menu/menu_service.dart b/client/lib/features/menu/menu_service.dart index 78efe6a..5ab9e4d 100644 --- a/client/lib/features/menu/menu_service.dart +++ b/client/lib/features/menu/menu_service.dart @@ -26,6 +26,22 @@ class MenuService { return MenuPlan.fromJson(data); } + /// Generates meals for specific [dates] (YYYY-MM-DD) and [mealTypes]. + /// Returns the updated MenuPlan for each affected week. + Future> generateForDates({ + required List dates, + required List mealTypes, + }) async { + final data = await _client.post('/ai/generate-menu', data: { + 'dates': dates, + 'meal_types': mealTypes, + }); + final plans = data['plans'] as List; + return plans + .map((planJson) => MenuPlan.fromJson(planJson as Map)) + .toList(); + } + Future updateMenuItem(String itemId, String recipeId) async { await _client.put('/menu/items/$itemId', data: {'recipe_id': recipeId}); } diff --git a/client/lib/features/menu/plan_date_picker_sheet.dart b/client/lib/features/menu/plan_date_picker_sheet.dart new file mode 100644 index 0000000..7d8f74d --- /dev/null +++ b/client/lib/features/menu/plan_date_picker_sheet.dart @@ -0,0 +1,584 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:food_ai/l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; + +import '../../shared/models/meal_type.dart'; +import '../profile/profile_provider.dart'; +import 'menu_provider.dart'; +import 'plan_menu_sheet.dart'; + +/// Bottom sheet that collects the user's date (or date range) and meal type +/// choices, then triggers partial menu generation. +class PlanDatePickerSheet extends ConsumerStatefulWidget { + const PlanDatePickerSheet({ + super.key, + required this.mode, + required this.defaultStart, + }); + + final PlanMode mode; + + /// First date to pre-select. Typically tomorrow or the day after the last + /// planned date. + final DateTime defaultStart; + + @override + ConsumerState createState() => + _PlanDatePickerSheetState(); +} + +class _PlanDatePickerSheetState extends ConsumerState { + late DateTime _selectedDate; + late DateTime _rangeStart; + late DateTime _rangeEnd; + bool _rangeSelectingEnd = false; + + // For singleMeal mode: selected meal type + String? _selectedMealType; + + bool _loading = false; + + @override + void initState() { + super.initState(); + _selectedDate = widget.defaultStart; + _rangeStart = widget.defaultStart; + final windowDays = widget.mode == PlanMode.week ? 6 : 2; + _rangeEnd = widget.defaultStart.add(Duration(days: windowDays)); + } + + List get _userMealTypes { + final profile = ref.read(profileProvider).valueOrNull; + return profile?.mealTypes ?? const ['breakfast', 'lunch', 'dinner']; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + String _formatDate(DateTime date) { + final locale = Localizations.localeOf(context).toLanguageTag(); + return DateFormat('d MMMM', locale).format(date); + } + + List _buildDateList() { + if (widget.mode == PlanMode.singleMeal || + widget.mode == PlanMode.singleDay) { + return [_formatApiDate(_selectedDate)]; + } + final dates = []; + var current = _rangeStart; + while (!current.isAfter(_rangeEnd)) { + dates.add(_formatApiDate(current)); + current = current.add(const Duration(days: 1)); + } + return dates; + } + + List _buildMealTypeList() { + if (widget.mode == PlanMode.singleMeal) { + return _selectedMealType != null ? [_selectedMealType!] : []; + } + return _userMealTypes; + } + + String _formatApiDate(DateTime date) => + '${date.year}-${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + + bool get _canSubmit { + if (widget.mode == PlanMode.singleMeal && _selectedMealType == null) { + return false; + } + if ((widget.mode == PlanMode.days || widget.mode == PlanMode.week) && + !_rangeEnd.isAfter(_rangeStart.subtract(const Duration(days: 1)))) { + return false; + } + return !_loading; + } + + Future _submit() async { + final dates = _buildDateList(); + final mealTypes = _buildMealTypeList(); + if (dates.isEmpty || mealTypes.isEmpty) return; + + setState(() => _loading = true); + try { + await ref.read(planMenuServiceProvider).generateForDates( + dates: dates, + mealTypes: mealTypes, + ); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.planSuccess), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + // ── Range picker interactions ────────────────────────────────────────────── + + void _onDayTapped(DateTime date) { + if (date.isBefore(DateTime.now().subtract(const Duration(days: 1)))) return; + + if (widget.mode == PlanMode.week) { + // Sliding 7-day window anchored to the tapped day. + setState(() { + _rangeStart = date; + _rangeEnd = date.add(const Duration(days: 6)); + _rangeSelectingEnd = false; + }); + return; + } + + // days mode: first tap = start, second tap = end. + if (!_rangeSelectingEnd) { + setState(() { + _rangeStart = date; + _rangeEnd = date.add(const Duration(days: 2)); + _rangeSelectingEnd = true; + }); + } else { + if (date.isBefore(_rangeStart)) { + setState(() { + _rangeStart = date; + _rangeSelectingEnd = true; + }); + } else { + setState(() { + _rangeEnd = date; + _rangeSelectingEnd = false; + }); + } + } + } + + // ── Build ────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title + Text( + _sheetTitle(l10n), + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // Date selector + if (widget.mode == PlanMode.singleMeal || + widget.mode == PlanMode.singleDay) + _DateStripSelector( + selected: _selectedDate, + onSelected: (date) => + setState(() => _selectedDate = date), + ) + else + _CalendarRangePicker( + rangeStart: _rangeStart, + rangeEnd: _rangeEnd, + onDayTapped: _onDayTapped, + ), + + const SizedBox(height: 16), + + // Meal type selector (only for singleMeal mode) + if (widget.mode == PlanMode.singleMeal) ...[ + Text(l10n.planSelectMealType, + style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + _MealTypeChips( + mealTypeIds: _userMealTypes, + selected: _selectedMealType, + onSelected: (id) => + setState(() => _selectedMealType = id), + ), + const SizedBox(height: 16), + ], + + // Summary line for range modes + if (widget.mode == PlanMode.days || + widget.mode == PlanMode.week) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + '${_formatDate(_rangeStart)} – ${_formatDate(_rangeEnd)}' + ' (${_rangeEnd.difference(_rangeStart).inDays + 1})', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ), + + // Generate button + FilledButton( + onPressed: _canSubmit ? _submit : null, + child: _loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(l10n.planGenerateButton), + ), + ], + ), + ), + ), + ), + ); + } + + String _sheetTitle(AppLocalizations l10n) => switch (widget.mode) { + PlanMode.singleMeal => l10n.planOptionSingleMeal, + PlanMode.singleDay => l10n.planOptionDay, + PlanMode.days => l10n.planOptionDays, + PlanMode.week => l10n.planOptionWeek, + }; +} + +// ── Date strip (horizontal scroll) ──────────────────────────────────────────── + +class _DateStripSelector extends StatefulWidget { + const _DateStripSelector({ + required this.selected, + required this.onSelected, + }); + + final DateTime selected; + final void Function(DateTime) onSelected; + + @override + State<_DateStripSelector> createState() => _DateStripSelectorState(); +} + +class _DateStripSelectorState extends State<_DateStripSelector> { + late final ScrollController _scrollController; + // Show 30 upcoming days (today excluded, starts tomorrow). + static const _futureDays = 30; + static const _itemWidth = 64.0; + + DateTime get _tomorrow => + DateTime.now().add(const Duration(days: 1)); + + List get _dates => List.generate( + _futureDays, (index) => _tomorrow.add(Duration(days: index))); + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + return SizedBox( + height: 72, + child: ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + itemCount: _dates.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final date = _dates[index]; + final isSelected = _isSameDay(date, widget.selected); + return GestureDetector( + onTap: () => widget.onSelected(date), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _itemWidth, + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('E', locale).format(date), + style: theme.textTheme.labelSmall?.copyWith( + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + '${date.day}', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + Text( + DateFormat('MMM', locale).format(date), + style: theme.textTheme.labelSmall?.copyWith( + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +// ── Calendar range picker ────────────────────────────────────────────────────── + +class _CalendarRangePicker extends StatefulWidget { + const _CalendarRangePicker({ + required this.rangeStart, + required this.rangeEnd, + required this.onDayTapped, + }); + + final DateTime rangeStart; + final DateTime rangeEnd; + final void Function(DateTime) onDayTapped; + + @override + State<_CalendarRangePicker> createState() => _CalendarRangePickerState(); +} + +class _CalendarRangePickerState extends State<_CalendarRangePicker> { + late DateTime _displayMonth; + + @override + void initState() { + super.initState(); + _displayMonth = + DateTime(widget.rangeStart.year, widget.rangeStart.month); + } + + void _prevMonth() { + final now = DateTime.now(); + if (_displayMonth.year == now.year && _displayMonth.month == now.month) { + return; // don't go into the past + } + setState(() { + _displayMonth = + DateTime(_displayMonth.year, _displayMonth.month - 1); + }); + } + + void _nextMonth() { + setState(() { + _displayMonth = + DateTime(_displayMonth.year, _displayMonth.month + 1); + }); + } + + bool _isInRange(DateTime date) { + final dayOnly = DateTime(date.year, date.month, date.day); + final start = + DateTime(widget.rangeStart.year, widget.rangeStart.month, widget.rangeStart.day); + final end = + DateTime(widget.rangeEnd.year, widget.rangeEnd.month, widget.rangeEnd.day); + return !dayOnly.isBefore(start) && !dayOnly.isAfter(end); + } + + bool _isRangeStart(DateTime date) => + date.year == widget.rangeStart.year && + date.month == widget.rangeStart.month && + date.day == widget.rangeStart.day; + + bool _isRangeEnd(DateTime date) => + date.year == widget.rangeEnd.year && + date.month == widget.rangeEnd.month && + date.day == widget.rangeEnd.day; + + bool _isPast(DateTime date) { + final today = DateTime.now(); + return date.isBefore(DateTime(today.year, today.month, today.day)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final monthLabel = + DateFormat('MMMM yyyy', locale).format(_displayMonth); + + // Build the grid: first day of the month offset + days in month. + final firstDay = DateTime(_displayMonth.year, _displayMonth.month, 1); + // ISO weekday: Mon=1, Sun=7; leading empty cells before day 1. + final leadingBlanks = (firstDay.weekday - 1) % 7; + final daysInMonth = + DateUtils.getDaysInMonth(_displayMonth.year, _displayMonth.month); + final totalCells = leadingBlanks + daysInMonth; + + return Column( + children: [ + // Month navigation + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _prevMonth, + ), + Text(monthLabel, style: theme.textTheme.titleMedium), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _nextMonth, + ), + ], + ), + // Day-of-week header + Row( + children: ['M', 'T', 'W', 'T', 'F', 'S', 'S'] + .map( + (dayLabel) => Expanded( + child: Center( + child: Text( + dayLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 4), + // Calendar grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + childAspectRatio: 1, + ), + itemCount: totalCells, + itemBuilder: (context, index) { + if (index < leadingBlanks) { + return const SizedBox.shrink(); + } + final dayNumber = index - leadingBlanks + 1; + final date = DateTime( + _displayMonth.year, _displayMonth.month, dayNumber); + final inRange = _isInRange(date); + final isStart = _isRangeStart(date); + final isEnd = _isRangeEnd(date); + final isPast = _isPast(date); + + Color bgColor = Colors.transparent; + Color textColor = theme.colorScheme.onSurface; + + if (isPast) { + // ignore: deprecated_member_use + textColor = theme.colorScheme.onSurface.withOpacity(0.3); + } else if (isStart || isEnd) { + bgColor = theme.colorScheme.primary; + textColor = theme.colorScheme.onPrimary; + } else if (inRange) { + bgColor = theme.colorScheme.primaryContainer; + textColor = theme.colorScheme.onPrimaryContainer; + } + + return GestureDetector( + onTap: isPast ? null : () => widget.onDayTapped(date), + child: Container( + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + '$dayNumber', + style: theme.textTheme.bodySmall?.copyWith( + color: textColor, + fontWeight: + (isStart || isEnd) ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + }, + ), + ], + ); + } +} + +// ── Meal type chips ──────────────────────────────────────────────────────────── + +class _MealTypeChips extends StatelessWidget { + const _MealTypeChips({ + required this.mealTypeIds, + required this.selected, + required this.onSelected, + }); + + final List mealTypeIds; + final String? selected; + final void Function(String) onSelected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Wrap( + spacing: 8, + runSpacing: 6, + children: mealTypeIds.map((mealTypeId) { + final option = mealTypeById(mealTypeId); + final label = + '${option?.emoji ?? ''} ${mealTypeLabel(mealTypeId, l10n)}'.trim(); + return ChoiceChip( + label: Text(label), + selected: selected == mealTypeId, + onSelected: (_) => onSelected(mealTypeId), + ); + }).toList(), + ); + } +} diff --git a/client/lib/features/menu/plan_menu_sheet.dart b/client/lib/features/menu/plan_menu_sheet.dart new file mode 100644 index 0000000..daf2db9 --- /dev/null +++ b/client/lib/features/menu/plan_menu_sheet.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:food_ai/l10n/app_localizations.dart'; + +/// The planning horizon selected by the user. +enum PlanMode { singleMeal, singleDay, days, week } + +/// Bottom sheet that lets the user choose a planning horizon. +/// Closes itself and calls [onModeSelected] with the chosen mode. +class PlanMenuSheet extends StatelessWidget { + const PlanMenuSheet({super.key, required this.onModeSelected}); + + final void Function(PlanMode mode) onModeSelected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.planMenuTitle, + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _PlanOptionTile( + icon: Icons.restaurant_outlined, + title: l10n.planOptionSingleMeal, + subtitle: l10n.planOptionSingleMealDesc, + onTap: () => _select(context, PlanMode.singleMeal), + ), + _PlanOptionTile( + icon: Icons.today_outlined, + title: l10n.planOptionDay, + subtitle: l10n.planOptionDayDesc, + onTap: () => _select(context, PlanMode.singleDay), + ), + _PlanOptionTile( + icon: Icons.date_range_outlined, + title: l10n.planOptionDays, + subtitle: l10n.planOptionDaysDesc, + onTap: () => _select(context, PlanMode.days), + ), + _PlanOptionTile( + icon: Icons.calendar_month_outlined, + title: l10n.planOptionWeek, + subtitle: l10n.planOptionWeekDesc, + onTap: () => _select(context, PlanMode.week), + ), + ], + ), + ), + ); + } + + void _select(BuildContext context, PlanMode mode) { + Navigator.pop(context); + onModeSelected(mode); + } +} + +class _PlanOptionTile extends StatelessWidget { + const _PlanOptionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text(title, style: theme.textTheme.titleSmall), + subtitle: Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + trailing: Icon(Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant), + onTap: onTap, + ), + ); + } +} diff --git a/client/lib/l10n/app_ar.arb b/client/lib/l10n/app_ar.arb index ce07ba4..c615961 100644 --- a/client/lib/l10n/app_ar.arb +++ b/client/lib/l10n/app_ar.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "تخطيط الأسبوع", "generateWeekSubtitle": "سيقوم الذكاء الاصطناعي بإنشاء قائمة طعام تشمل الإفطار والغداء والعشاء لكامل الأسبوع", "generatingMenu": "جارٍ إنشاء القائمة...", - "weekPlannedLabel": "تم تخطيط الأسبوع" + "weekPlannedLabel": "تم تخطيط الأسبوع", + + "planMenuButton": "تخطيط الوجبات", + "planMenuTitle": "ماذا تريد تخطيطه؟", + "planOptionSingleMeal": "وجبة واحدة", + "planOptionSingleMealDesc": "اختر اليوم ونوع الوجبة", + "planOptionDay": "يوم واحد", + "planOptionDayDesc": "جميع وجبات اليوم", + "planOptionDays": "عدة أيام", + "planOptionDaysDesc": "تخصيص الفترة", + "planOptionWeek": "أسبوع", + "planOptionWeekDesc": "7 أيام دفعة واحدة", + "planSelectDate": "اختر التاريخ", + "planSelectMealType": "نوع الوجبة", + "planSelectRange": "اختر الفترة", + "planGenerateButton": "تخطيط", + "planGenerating": "جارٍ إنشاء الخطة\u2026", + "planSuccess": "تم تخطيط القائمة!" } diff --git a/client/lib/l10n/app_de.arb b/client/lib/l10n/app_de.arb index 760413c..2eab697 100644 --- a/client/lib/l10n/app_de.arb +++ b/client/lib/l10n/app_de.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "Woche planen", "generateWeekSubtitle": "KI erstellt einen Menüplan mit Frühstück, Mittagessen und Abendessen für die ganze Woche", "generatingMenu": "Menü wird erstellt...", - "weekPlannedLabel": "Woche geplant" + "weekPlannedLabel": "Woche geplant", + + "planMenuButton": "Mahlzeiten planen", + "planMenuTitle": "Was planen?", + "planOptionSingleMeal": "Einzelne Mahlzeit", + "planOptionSingleMealDesc": "Tag und Mahlzeittyp wählen", + "planOptionDay": "Ein Tag", + "planOptionDayDesc": "Alle Mahlzeiten für einen Tag", + "planOptionDays": "Mehrere Tage", + "planOptionDaysDesc": "Zeitraum anpassen", + "planOptionWeek": "Eine Woche", + "planOptionWeekDesc": "7 Tage auf einmal", + "planSelectDate": "Datum wählen", + "planSelectMealType": "Mahlzeittyp", + "planSelectRange": "Zeitraum wählen", + "planGenerateButton": "Planen", + "planGenerating": "Plan wird erstellt\u2026", + "planSuccess": "Menü geplant!" } diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index 178a1be..0ae94e6 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -133,5 +133,22 @@ "generateWeekLabel": "Plan the week", "generateWeekSubtitle": "AI will create a menu with breakfast, lunch and dinner for the whole week", "generatingMenu": "Generating menu...", - "weekPlannedLabel": "Week planned" + "weekPlannedLabel": "Week planned", + + "planMenuButton": "Plan meals", + "planMenuTitle": "What to plan?", + "planOptionSingleMeal": "Single meal", + "planOptionSingleMealDesc": "Choose a day and meal type", + "planOptionDay": "One day", + "planOptionDayDesc": "All meals for one day", + "planOptionDays": "Several days", + "planOptionDaysDesc": "Custom date range", + "planOptionWeek": "A week", + "planOptionWeekDesc": "7 days at once", + "planSelectDate": "Select date", + "planSelectMealType": "Meal type", + "planSelectRange": "Select period", + "planGenerateButton": "Plan", + "planGenerating": "Generating plan\u2026", + "planSuccess": "Menu planned!" } diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index f142cbb..5cbb154 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "Planificar la semana", "generateWeekSubtitle": "La IA creará un menú con desayuno, comida y cena para toda la semana", "generatingMenu": "Generando menú...", - "weekPlannedLabel": "Semana planificada" + "weekPlannedLabel": "Semana planificada", + + "planMenuButton": "Planificar comidas", + "planMenuTitle": "¿Qué planificar?", + "planOptionSingleMeal": "Una comida", + "planOptionSingleMealDesc": "Elegir día y tipo de comida", + "planOptionDay": "Un día", + "planOptionDayDesc": "Todas las comidas de un día", + "planOptionDays": "Varios días", + "planOptionDaysDesc": "Personalizar período", + "planOptionWeek": "Una semana", + "planOptionWeekDesc": "7 días de una vez", + "planSelectDate": "Seleccionar fecha", + "planSelectMealType": "Tipo de comida", + "planSelectRange": "Seleccionar período", + "planGenerateButton": "Planificar", + "planGenerating": "Generando plan\u2026", + "planSuccess": "¡Menú planificado!" } diff --git a/client/lib/l10n/app_fr.arb b/client/lib/l10n/app_fr.arb index 7aeed43..4e49951 100644 --- a/client/lib/l10n/app_fr.arb +++ b/client/lib/l10n/app_fr.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "Planifier la semaine", "generateWeekSubtitle": "L'IA créera un menu avec petit-déjeuner, déjeuner et dîner pour toute la semaine", "generatingMenu": "Génération du menu...", - "weekPlannedLabel": "Semaine planifiée" + "weekPlannedLabel": "Semaine planifiée", + + "planMenuButton": "Planifier les repas", + "planMenuTitle": "Que planifier ?", + "planOptionSingleMeal": "Un repas", + "planOptionSingleMealDesc": "Choisir un jour et un type de repas", + "planOptionDay": "Un jour", + "planOptionDayDesc": "Tous les repas d'une journée", + "planOptionDays": "Plusieurs jours", + "planOptionDaysDesc": "Personnaliser la période", + "planOptionWeek": "Une semaine", + "planOptionWeekDesc": "7 jours d'un coup", + "planSelectDate": "Choisir une date", + "planSelectMealType": "Type de repas", + "planSelectRange": "Choisir la période", + "planGenerateButton": "Planifier", + "planGenerating": "Génération du plan\u2026", + "planSuccess": "Menu planifié !" } diff --git a/client/lib/l10n/app_hi.arb b/client/lib/l10n/app_hi.arb index 69d7019..5d1f96a 100644 --- a/client/lib/l10n/app_hi.arb +++ b/client/lib/l10n/app_hi.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "सप्ताह की योजना बनाएं", "generateWeekSubtitle": "AI पूरे सप्ताह के लिए नाश्ता, दोपहर का खाना और रात के खाने के साथ मेनू बनाएगा", "generatingMenu": "मेनू बना रहे हैं...", - "weekPlannedLabel": "सप्ताह की योजना बनाई गई" + "weekPlannedLabel": "सप्ताह की योजना बनाई गई", + + "planMenuButton": "भोजन की योजना बनाएं", + "planMenuTitle": "क्या योजना बनानी है?", + "planOptionSingleMeal": "एक भोजन", + "planOptionSingleMealDesc": "दिन और भोजन का प्रकार चुनें", + "planOptionDay": "एक दिन", + "planOptionDayDesc": "एक दिन के सभी भोजन", + "planOptionDays": "कई दिन", + "planOptionDaysDesc": "अवधि अनुकूलित करें", + "planOptionWeek": "एक सप्ताह", + "planOptionWeekDesc": "एक बार में 7 दिन", + "planSelectDate": "तारीख चुनें", + "planSelectMealType": "भोजन का प्रकार", + "planSelectRange": "अवधि चुनें", + "planGenerateButton": "योजना बनाएं", + "planGenerating": "योजना बना रहे हैं\u2026", + "planSuccess": "मेनू की योजना बनाई गई!" } diff --git a/client/lib/l10n/app_it.arb b/client/lib/l10n/app_it.arb index 7249e6f..80dc5b2 100644 --- a/client/lib/l10n/app_it.arb +++ b/client/lib/l10n/app_it.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "Pianifica la settimana", "generateWeekSubtitle": "L'AI creerà un menu con colazione, pranzo e cena per tutta la settimana", "generatingMenu": "Generazione menu...", - "weekPlannedLabel": "Settimana pianificata" + "weekPlannedLabel": "Settimana pianificata", + + "planMenuButton": "Pianifica i pasti", + "planMenuTitle": "Cosa pianificare?", + "planOptionSingleMeal": "Un pasto", + "planOptionSingleMealDesc": "Scegli giorno e tipo di pasto", + "planOptionDay": "Un giorno", + "planOptionDayDesc": "Tutti i pasti di un giorno", + "planOptionDays": "Più giorni", + "planOptionDaysDesc": "Personalizza il periodo", + "planOptionWeek": "Una settimana", + "planOptionWeekDesc": "7 giorni in una volta", + "planSelectDate": "Seleziona data", + "planSelectMealType": "Tipo di pasto", + "planSelectRange": "Seleziona periodo", + "planGenerateButton": "Pianifica", + "planGenerating": "Generazione piano\u2026", + "planSuccess": "Menu pianificato!" } diff --git a/client/lib/l10n/app_ja.arb b/client/lib/l10n/app_ja.arb index fd3c146..b6185fc 100644 --- a/client/lib/l10n/app_ja.arb +++ b/client/lib/l10n/app_ja.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "週を計画する", "generateWeekSubtitle": "AIが一週間の朝食・昼食・夕食のメニューを作成します", "generatingMenu": "メニューを生成中...", - "weekPlannedLabel": "週の計画済み" + "weekPlannedLabel": "週の計画済み", + + "planMenuButton": "食事を計画する", + "planMenuTitle": "何を計画する?", + "planOptionSingleMeal": "1食", + "planOptionSingleMealDesc": "日と食事タイプを選択", + "planOptionDay": "1日", + "planOptionDayDesc": "1日分の全食事", + "planOptionDays": "数日", + "planOptionDaysDesc": "期間をカスタマイズ", + "planOptionWeek": "1週間", + "planOptionWeekDesc": "7日分まとめて", + "planSelectDate": "日付を選択", + "planSelectMealType": "食事タイプ", + "planSelectRange": "期間を選択", + "planGenerateButton": "計画する", + "planGenerating": "プランを生成中\u2026", + "planSuccess": "メニューが計画されました!" } diff --git a/client/lib/l10n/app_ko.arb b/client/lib/l10n/app_ko.arb index c49787e..40c4cf8 100644 --- a/client/lib/l10n/app_ko.arb +++ b/client/lib/l10n/app_ko.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "주간 계획하기", "generateWeekSubtitle": "AI가 한 주 동안 아침, 점심, 저녁 식사 메뉴를 만들어 드립니다", "generatingMenu": "메뉴 생성 중...", - "weekPlannedLabel": "주간 계획 완료" + "weekPlannedLabel": "주간 계획 완료", + + "planMenuButton": "식사 계획하기", + "planMenuTitle": "무엇을 계획하시겠어요?", + "planOptionSingleMeal": "식사 1회", + "planOptionSingleMealDesc": "날짜와 식사 유형 선택", + "planOptionDay": "하루", + "planOptionDayDesc": "하루 전체 식사", + "planOptionDays": "며칠", + "planOptionDaysDesc": "기간 직접 설정", + "planOptionWeek": "일주일", + "planOptionWeekDesc": "7일 한 번에", + "planSelectDate": "날짜 선택", + "planSelectMealType": "식사 유형", + "planSelectRange": "기간 선택", + "planGenerateButton": "계획하기", + "planGenerating": "플랜 생성 중\u2026", + "planSuccess": "메뉴가 계획되었습니다!" } diff --git a/client/lib/l10n/app_localizations.dart b/client/lib/l10n/app_localizations.dart index e11d961..564ff31 100644 --- a/client/lib/l10n/app_localizations.dart +++ b/client/lib/l10n/app_localizations.dart @@ -831,6 +831,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Week planned'** String get weekPlannedLabel; + + /// No description provided for @planMenuButton. + /// + /// In en, this message translates to: + /// **'Plan meals'** + String get planMenuButton; + + /// No description provided for @planMenuTitle. + /// + /// In en, this message translates to: + /// **'What to plan?'** + String get planMenuTitle; + + /// No description provided for @planOptionSingleMeal. + /// + /// In en, this message translates to: + /// **'Single meal'** + String get planOptionSingleMeal; + + /// No description provided for @planOptionSingleMealDesc. + /// + /// In en, this message translates to: + /// **'Choose a day and meal type'** + String get planOptionSingleMealDesc; + + /// No description provided for @planOptionDay. + /// + /// In en, this message translates to: + /// **'One day'** + String get planOptionDay; + + /// No description provided for @planOptionDayDesc. + /// + /// In en, this message translates to: + /// **'All meals for one day'** + String get planOptionDayDesc; + + /// No description provided for @planOptionDays. + /// + /// In en, this message translates to: + /// **'Several days'** + String get planOptionDays; + + /// No description provided for @planOptionDaysDesc. + /// + /// In en, this message translates to: + /// **'Custom date range'** + String get planOptionDaysDesc; + + /// No description provided for @planOptionWeek. + /// + /// In en, this message translates to: + /// **'A week'** + String get planOptionWeek; + + /// No description provided for @planOptionWeekDesc. + /// + /// In en, this message translates to: + /// **'7 days at once'** + String get planOptionWeekDesc; + + /// No description provided for @planSelectDate. + /// + /// In en, this message translates to: + /// **'Select date'** + String get planSelectDate; + + /// No description provided for @planSelectMealType. + /// + /// In en, this message translates to: + /// **'Meal type'** + String get planSelectMealType; + + /// No description provided for @planSelectRange. + /// + /// In en, this message translates to: + /// **'Select period'** + String get planSelectRange; + + /// No description provided for @planGenerateButton. + /// + /// In en, this message translates to: + /// **'Plan'** + String get planGenerateButton; + + /// No description provided for @planGenerating. + /// + /// In en, this message translates to: + /// **'Generating plan…'** + String get planGenerating; + + /// No description provided for @planSuccess. + /// + /// In en, this message translates to: + /// **'Menu planned!'** + String get planSuccess; } class _AppLocalizationsDelegate diff --git a/client/lib/l10n/app_localizations_ar.dart b/client/lib/l10n/app_localizations_ar.dart index 28fb97b..61fae2a 100644 --- a/client/lib/l10n/app_localizations_ar.dart +++ b/client/lib/l10n/app_localizations_ar.dart @@ -372,4 +372,52 @@ class AppLocalizationsAr extends AppLocalizations { @override String get weekPlannedLabel => 'تم تخطيط الأسبوع'; + + @override + String get planMenuButton => 'تخطيط الوجبات'; + + @override + String get planMenuTitle => 'ماذا تريد تخطيطه؟'; + + @override + String get planOptionSingleMeal => 'وجبة واحدة'; + + @override + String get planOptionSingleMealDesc => 'اختر اليوم ونوع الوجبة'; + + @override + String get planOptionDay => 'يوم واحد'; + + @override + String get planOptionDayDesc => 'جميع وجبات اليوم'; + + @override + String get planOptionDays => 'عدة أيام'; + + @override + String get planOptionDaysDesc => 'تخصيص الفترة'; + + @override + String get planOptionWeek => 'أسبوع'; + + @override + String get planOptionWeekDesc => '7 أيام دفعة واحدة'; + + @override + String get planSelectDate => 'اختر التاريخ'; + + @override + String get planSelectMealType => 'نوع الوجبة'; + + @override + String get planSelectRange => 'اختر الفترة'; + + @override + String get planGenerateButton => 'تخطيط'; + + @override + String get planGenerating => 'جارٍ إنشاء الخطة…'; + + @override + String get planSuccess => 'تم تخطيط القائمة!'; } diff --git a/client/lib/l10n/app_localizations_de.dart b/client/lib/l10n/app_localizations_de.dart index 1eeac3d..233938e 100644 --- a/client/lib/l10n/app_localizations_de.dart +++ b/client/lib/l10n/app_localizations_de.dart @@ -374,4 +374,52 @@ class AppLocalizationsDe extends AppLocalizations { @override String get weekPlannedLabel => 'Woche geplant'; + + @override + String get planMenuButton => 'Mahlzeiten planen'; + + @override + String get planMenuTitle => 'Was planen?'; + + @override + String get planOptionSingleMeal => 'Einzelne Mahlzeit'; + + @override + String get planOptionSingleMealDesc => 'Tag und Mahlzeittyp wählen'; + + @override + String get planOptionDay => 'Ein Tag'; + + @override + String get planOptionDayDesc => 'Alle Mahlzeiten für einen Tag'; + + @override + String get planOptionDays => 'Mehrere Tage'; + + @override + String get planOptionDaysDesc => 'Zeitraum anpassen'; + + @override + String get planOptionWeek => 'Eine Woche'; + + @override + String get planOptionWeekDesc => '7 Tage auf einmal'; + + @override + String get planSelectDate => 'Datum wählen'; + + @override + String get planSelectMealType => 'Mahlzeittyp'; + + @override + String get planSelectRange => 'Zeitraum wählen'; + + @override + String get planGenerateButton => 'Planen'; + + @override + String get planGenerating => 'Plan wird erstellt…'; + + @override + String get planSuccess => 'Menü geplant!'; } diff --git a/client/lib/l10n/app_localizations_en.dart b/client/lib/l10n/app_localizations_en.dart index ce7eb31..f46fba5 100644 --- a/client/lib/l10n/app_localizations_en.dart +++ b/client/lib/l10n/app_localizations_en.dart @@ -372,4 +372,52 @@ class AppLocalizationsEn extends AppLocalizations { @override String get weekPlannedLabel => 'Week planned'; + + @override + String get planMenuButton => 'Plan meals'; + + @override + String get planMenuTitle => 'What to plan?'; + + @override + String get planOptionSingleMeal => 'Single meal'; + + @override + String get planOptionSingleMealDesc => 'Choose a day and meal type'; + + @override + String get planOptionDay => 'One day'; + + @override + String get planOptionDayDesc => 'All meals for one day'; + + @override + String get planOptionDays => 'Several days'; + + @override + String get planOptionDaysDesc => 'Custom date range'; + + @override + String get planOptionWeek => 'A week'; + + @override + String get planOptionWeekDesc => '7 days at once'; + + @override + String get planSelectDate => 'Select date'; + + @override + String get planSelectMealType => 'Meal type'; + + @override + String get planSelectRange => 'Select period'; + + @override + String get planGenerateButton => 'Plan'; + + @override + String get planGenerating => 'Generating plan…'; + + @override + String get planSuccess => 'Menu planned!'; } diff --git a/client/lib/l10n/app_localizations_es.dart b/client/lib/l10n/app_localizations_es.dart index 75725ca..65d03ea 100644 --- a/client/lib/l10n/app_localizations_es.dart +++ b/client/lib/l10n/app_localizations_es.dart @@ -374,4 +374,52 @@ class AppLocalizationsEs extends AppLocalizations { @override String get weekPlannedLabel => 'Semana planificada'; + + @override + String get planMenuButton => 'Planificar comidas'; + + @override + String get planMenuTitle => '¿Qué planificar?'; + + @override + String get planOptionSingleMeal => 'Una comida'; + + @override + String get planOptionSingleMealDesc => 'Elegir día y tipo de comida'; + + @override + String get planOptionDay => 'Un día'; + + @override + String get planOptionDayDesc => 'Todas las comidas de un día'; + + @override + String get planOptionDays => 'Varios días'; + + @override + String get planOptionDaysDesc => 'Personalizar período'; + + @override + String get planOptionWeek => 'Una semana'; + + @override + String get planOptionWeekDesc => '7 días de una vez'; + + @override + String get planSelectDate => 'Seleccionar fecha'; + + @override + String get planSelectMealType => 'Tipo de comida'; + + @override + String get planSelectRange => 'Seleccionar período'; + + @override + String get planGenerateButton => 'Planificar'; + + @override + String get planGenerating => 'Generando plan…'; + + @override + String get planSuccess => '¡Menú planificado!'; } diff --git a/client/lib/l10n/app_localizations_fr.dart b/client/lib/l10n/app_localizations_fr.dart index 53a70d5..d586eea 100644 --- a/client/lib/l10n/app_localizations_fr.dart +++ b/client/lib/l10n/app_localizations_fr.dart @@ -375,4 +375,52 @@ class AppLocalizationsFr extends AppLocalizations { @override String get weekPlannedLabel => 'Semaine planifiée'; + + @override + String get planMenuButton => 'Planifier les repas'; + + @override + String get planMenuTitle => 'Que planifier ?'; + + @override + String get planOptionSingleMeal => 'Un repas'; + + @override + String get planOptionSingleMealDesc => 'Choisir un jour et un type de repas'; + + @override + String get planOptionDay => 'Un jour'; + + @override + String get planOptionDayDesc => 'Tous les repas d\'une journée'; + + @override + String get planOptionDays => 'Plusieurs jours'; + + @override + String get planOptionDaysDesc => 'Personnaliser la période'; + + @override + String get planOptionWeek => 'Une semaine'; + + @override + String get planOptionWeekDesc => '7 jours d\'un coup'; + + @override + String get planSelectDate => 'Choisir une date'; + + @override + String get planSelectMealType => 'Type de repas'; + + @override + String get planSelectRange => 'Choisir la période'; + + @override + String get planGenerateButton => 'Planifier'; + + @override + String get planGenerating => 'Génération du plan…'; + + @override + String get planSuccess => 'Menu planifié !'; } diff --git a/client/lib/l10n/app_localizations_hi.dart b/client/lib/l10n/app_localizations_hi.dart index e66ac9d..8a88e96 100644 --- a/client/lib/l10n/app_localizations_hi.dart +++ b/client/lib/l10n/app_localizations_hi.dart @@ -373,4 +373,52 @@ class AppLocalizationsHi extends AppLocalizations { @override String get weekPlannedLabel => 'सप्ताह की योजना बनाई गई'; + + @override + String get planMenuButton => 'भोजन की योजना बनाएं'; + + @override + String get planMenuTitle => 'क्या योजना बनानी है?'; + + @override + String get planOptionSingleMeal => 'एक भोजन'; + + @override + String get planOptionSingleMealDesc => 'दिन और भोजन का प्रकार चुनें'; + + @override + String get planOptionDay => 'एक दिन'; + + @override + String get planOptionDayDesc => 'एक दिन के सभी भोजन'; + + @override + String get planOptionDays => 'कई दिन'; + + @override + String get planOptionDaysDesc => 'अवधि अनुकूलित करें'; + + @override + String get planOptionWeek => 'एक सप्ताह'; + + @override + String get planOptionWeekDesc => 'एक बार में 7 दिन'; + + @override + String get planSelectDate => 'तारीख चुनें'; + + @override + String get planSelectMealType => 'भोजन का प्रकार'; + + @override + String get planSelectRange => 'अवधि चुनें'; + + @override + String get planGenerateButton => 'योजना बनाएं'; + + @override + String get planGenerating => 'योजना बना रहे हैं…'; + + @override + String get planSuccess => 'मेनू की योजना बनाई गई!'; } diff --git a/client/lib/l10n/app_localizations_it.dart b/client/lib/l10n/app_localizations_it.dart index 153c465..a43466f 100644 --- a/client/lib/l10n/app_localizations_it.dart +++ b/client/lib/l10n/app_localizations_it.dart @@ -374,4 +374,52 @@ class AppLocalizationsIt extends AppLocalizations { @override String get weekPlannedLabel => 'Settimana pianificata'; + + @override + String get planMenuButton => 'Pianifica i pasti'; + + @override + String get planMenuTitle => 'Cosa pianificare?'; + + @override + String get planOptionSingleMeal => 'Un pasto'; + + @override + String get planOptionSingleMealDesc => 'Scegli giorno e tipo di pasto'; + + @override + String get planOptionDay => 'Un giorno'; + + @override + String get planOptionDayDesc => 'Tutti i pasti di un giorno'; + + @override + String get planOptionDays => 'Più giorni'; + + @override + String get planOptionDaysDesc => 'Personalizza il periodo'; + + @override + String get planOptionWeek => 'Una settimana'; + + @override + String get planOptionWeekDesc => '7 giorni in una volta'; + + @override + String get planSelectDate => 'Seleziona data'; + + @override + String get planSelectMealType => 'Tipo di pasto'; + + @override + String get planSelectRange => 'Seleziona periodo'; + + @override + String get planGenerateButton => 'Pianifica'; + + @override + String get planGenerating => 'Generazione piano…'; + + @override + String get planSuccess => 'Menu pianificato!'; } diff --git a/client/lib/l10n/app_localizations_ja.dart b/client/lib/l10n/app_localizations_ja.dart index 22a0fdb..8991ccf 100644 --- a/client/lib/l10n/app_localizations_ja.dart +++ b/client/lib/l10n/app_localizations_ja.dart @@ -370,4 +370,52 @@ class AppLocalizationsJa extends AppLocalizations { @override String get weekPlannedLabel => '週の計画済み'; + + @override + String get planMenuButton => '食事を計画する'; + + @override + String get planMenuTitle => '何を計画する?'; + + @override + String get planOptionSingleMeal => '1食'; + + @override + String get planOptionSingleMealDesc => '日と食事タイプを選択'; + + @override + String get planOptionDay => '1日'; + + @override + String get planOptionDayDesc => '1日分の全食事'; + + @override + String get planOptionDays => '数日'; + + @override + String get planOptionDaysDesc => '期間をカスタマイズ'; + + @override + String get planOptionWeek => '1週間'; + + @override + String get planOptionWeekDesc => '7日分まとめて'; + + @override + String get planSelectDate => '日付を選択'; + + @override + String get planSelectMealType => '食事タイプ'; + + @override + String get planSelectRange => '期間を選択'; + + @override + String get planGenerateButton => '計画する'; + + @override + String get planGenerating => 'プランを生成中…'; + + @override + String get planSuccess => 'メニューが計画されました!'; } diff --git a/client/lib/l10n/app_localizations_ko.dart b/client/lib/l10n/app_localizations_ko.dart index c3256b6..c77b316 100644 --- a/client/lib/l10n/app_localizations_ko.dart +++ b/client/lib/l10n/app_localizations_ko.dart @@ -370,4 +370,52 @@ class AppLocalizationsKo extends AppLocalizations { @override String get weekPlannedLabel => '주간 계획 완료'; + + @override + String get planMenuButton => '식사 계획하기'; + + @override + String get planMenuTitle => '무엇을 계획하시겠어요?'; + + @override + String get planOptionSingleMeal => '식사 1회'; + + @override + String get planOptionSingleMealDesc => '날짜와 식사 유형 선택'; + + @override + String get planOptionDay => '하루'; + + @override + String get planOptionDayDesc => '하루 전체 식사'; + + @override + String get planOptionDays => '며칠'; + + @override + String get planOptionDaysDesc => '기간 직접 설정'; + + @override + String get planOptionWeek => '일주일'; + + @override + String get planOptionWeekDesc => '7일 한 번에'; + + @override + String get planSelectDate => '날짜 선택'; + + @override + String get planSelectMealType => '식사 유형'; + + @override + String get planSelectRange => '기간 선택'; + + @override + String get planGenerateButton => '계획하기'; + + @override + String get planGenerating => '플랜 생성 중…'; + + @override + String get planSuccess => '메뉴가 계획되었습니다!'; } diff --git a/client/lib/l10n/app_localizations_pt.dart b/client/lib/l10n/app_localizations_pt.dart index ed7fae5..941ea84 100644 --- a/client/lib/l10n/app_localizations_pt.dart +++ b/client/lib/l10n/app_localizations_pt.dart @@ -374,4 +374,52 @@ class AppLocalizationsPt extends AppLocalizations { @override String get weekPlannedLabel => 'Semana planejada'; + + @override + String get planMenuButton => 'Planejar refeições'; + + @override + String get planMenuTitle => 'O que planejar?'; + + @override + String get planOptionSingleMeal => 'Uma refeição'; + + @override + String get planOptionSingleMealDesc => 'Escolher dia e tipo de refeição'; + + @override + String get planOptionDay => 'Um dia'; + + @override + String get planOptionDayDesc => 'Todas as refeições de um dia'; + + @override + String get planOptionDays => 'Vários dias'; + + @override + String get planOptionDaysDesc => 'Personalizar período'; + + @override + String get planOptionWeek => 'Uma semana'; + + @override + String get planOptionWeekDesc => '7 dias de uma vez'; + + @override + String get planSelectDate => 'Selecionar data'; + + @override + String get planSelectMealType => 'Tipo de refeição'; + + @override + String get planSelectRange => 'Selecionar período'; + + @override + String get planGenerateButton => 'Planejar'; + + @override + String get planGenerating => 'Gerando plano…'; + + @override + String get planSuccess => 'Menu planejado!'; } diff --git a/client/lib/l10n/app_localizations_ru.dart b/client/lib/l10n/app_localizations_ru.dart index 1a108e8..bf28095 100644 --- a/client/lib/l10n/app_localizations_ru.dart +++ b/client/lib/l10n/app_localizations_ru.dart @@ -372,4 +372,52 @@ class AppLocalizationsRu extends AppLocalizations { @override String get weekPlannedLabel => 'Неделя запланирована'; + + @override + String get planMenuButton => 'Спланировать меню'; + + @override + String get planMenuTitle => 'Что запланировать?'; + + @override + String get planOptionSingleMeal => '1 приём пищи'; + + @override + String get planOptionSingleMealDesc => 'Выберите день и приём пищи'; + + @override + String get planOptionDay => '1 день'; + + @override + String get planOptionDayDesc => 'Все приёмы пищи за день'; + + @override + String get planOptionDays => 'Несколько дней'; + + @override + String get planOptionDaysDesc => 'Настроить период'; + + @override + String get planOptionWeek => 'Неделя'; + + @override + String get planOptionWeekDesc => '7 дней сразу'; + + @override + String get planSelectDate => 'Выберите дату'; + + @override + String get planSelectMealType => 'Приём пищи'; + + @override + String get planSelectRange => 'Выберите период'; + + @override + String get planGenerateButton => 'Запланировать'; + + @override + String get planGenerating => 'Генерирую план…'; + + @override + String get planSuccess => 'Меню запланировано!'; } diff --git a/client/lib/l10n/app_localizations_zh.dart b/client/lib/l10n/app_localizations_zh.dart index 33e8b89..be1a17e 100644 --- a/client/lib/l10n/app_localizations_zh.dart +++ b/client/lib/l10n/app_localizations_zh.dart @@ -370,4 +370,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get weekPlannedLabel => '本周已规划'; + + @override + String get planMenuButton => '规划餐食'; + + @override + String get planMenuTitle => '规划什么?'; + + @override + String get planOptionSingleMeal => '单次餐食'; + + @override + String get planOptionSingleMealDesc => '选择日期和餐食类型'; + + @override + String get planOptionDay => '一天'; + + @override + String get planOptionDayDesc => '一天的所有餐食'; + + @override + String get planOptionDays => '几天'; + + @override + String get planOptionDaysDesc => '自定义日期范围'; + + @override + String get planOptionWeek => '一周'; + + @override + String get planOptionWeekDesc => '一次规划7天'; + + @override + String get planSelectDate => '选择日期'; + + @override + String get planSelectMealType => '餐食类型'; + + @override + String get planSelectRange => '选择时间段'; + + @override + String get planGenerateButton => '规划'; + + @override + String get planGenerating => '正在生成计划…'; + + @override + String get planSuccess => '菜单已规划!'; } diff --git a/client/lib/l10n/app_pt.arb b/client/lib/l10n/app_pt.arb index 3a616fd..c552cbf 100644 --- a/client/lib/l10n/app_pt.arb +++ b/client/lib/l10n/app_pt.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "Planejar a semana", "generateWeekSubtitle": "A IA criará um menu com café da manhã, almoço e jantar para a semana inteira", "generatingMenu": "Gerando menu...", - "weekPlannedLabel": "Semana planejada" + "weekPlannedLabel": "Semana planejada", + + "planMenuButton": "Planejar refeições", + "planMenuTitle": "O que planejar?", + "planOptionSingleMeal": "Uma refeição", + "planOptionSingleMealDesc": "Escolher dia e tipo de refeição", + "planOptionDay": "Um dia", + "planOptionDayDesc": "Todas as refeições de um dia", + "planOptionDays": "Vários dias", + "planOptionDaysDesc": "Personalizar período", + "planOptionWeek": "Uma semana", + "planOptionWeekDesc": "7 dias de uma vez", + "planSelectDate": "Selecionar data", + "planSelectMealType": "Tipo de refeição", + "planSelectRange": "Selecionar período", + "planGenerateButton": "Planejar", + "planGenerating": "Gerando plano\u2026", + "planSuccess": "Menu planejado!" } diff --git a/client/lib/l10n/app_ru.arb b/client/lib/l10n/app_ru.arb index 4b5a056..01e0315 100644 --- a/client/lib/l10n/app_ru.arb +++ b/client/lib/l10n/app_ru.arb @@ -133,5 +133,22 @@ "generateWeekLabel": "Запланировать неделю", "generateWeekSubtitle": "AI составит меню с завтраком, обедом и ужином на всю неделю", "generatingMenu": "Генерируем меню...", - "weekPlannedLabel": "Неделя запланирована" + "weekPlannedLabel": "Неделя запланирована", + + "planMenuButton": "Спланировать меню", + "planMenuTitle": "Что запланировать?", + "planOptionSingleMeal": "1 приём пищи", + "planOptionSingleMealDesc": "Выберите день и приём пищи", + "planOptionDay": "1 день", + "planOptionDayDesc": "Все приёмы пищи за день", + "planOptionDays": "Несколько дней", + "planOptionDaysDesc": "Настроить период", + "planOptionWeek": "Неделя", + "planOptionWeekDesc": "7 дней сразу", + "planSelectDate": "Выберите дату", + "planSelectMealType": "Приём пищи", + "planSelectRange": "Выберите период", + "planGenerateButton": "Запланировать", + "planGenerating": "Генерирую план\u2026", + "planSuccess": "Меню запланировано!" } diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index 372e3b9..2e79bda 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -135,5 +135,22 @@ "generateWeekLabel": "规划本周", "generateWeekSubtitle": "AI将为整周创建含早餐、午餐和晚餐的菜单", "generatingMenu": "正在生成菜单...", - "weekPlannedLabel": "本周已规划" + "weekPlannedLabel": "本周已规划", + + "planMenuButton": "规划餐食", + "planMenuTitle": "规划什么?", + "planOptionSingleMeal": "单次餐食", + "planOptionSingleMealDesc": "选择日期和餐食类型", + "planOptionDay": "一天", + "planOptionDayDesc": "一天的所有餐食", + "planOptionDays": "几天", + "planOptionDaysDesc": "自定义日期范围", + "planOptionWeek": "一周", + "planOptionWeekDesc": "一次规划7天", + "planSelectDate": "选择日期", + "planSelectMealType": "餐食类型", + "planSelectRange": "选择时间段", + "planGenerateButton": "规划", + "planGenerating": "正在生成计划\u2026", + "planSuccess": "菜单已规划!" }