2.0: dish editor gains photo picker and ingredients section

- PhotosPicker with downscaling (1024pt max, JPEG 0.8) before storing in
  photoData; change/remove actions.
- Ingredients: editable line list (add/edit/remove) + on-device "Generate"
  from the dish name (hidden when Foundation Models unavailable; paywall
  after the free generation quota, source=ingredient_generation).
- DishViewModel: drafts ingredients/photo, cleans empty lines on save.
- Localized in all 6 languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
alexandrev-tibco
2026-07-12 17:51:33 +02:00
parent ce88d7b265
commit 98230c732d
8 changed files with 231 additions and 2 deletions
@@ -397,3 +397,12 @@
"shopping_empty_title" = "Noch nichts einzukaufen";
"shopping_empty_message" = "Plane Gerichte für diese Woche und ihre Zutaten erscheinen hier.";
"shopping_share_header" = "🛒 Einkaufsliste — MealMood";
// Dish editor: photo + ingredients (2.0)
"dish_photo_label" = "Foto";
"dish_photo_add" = "Foto hinzufügen";
"dish_photo_change" = "Foto ändern";
"dish_photo_remove" = "Entfernen";
"dish_ingredients_label" = "Zutaten";
"dish_ingredient_placeholder" = "z. B. 200 g Spaghetti";
"dish_ingredients_add" = "Zutat hinzufügen";
@@ -397,3 +397,12 @@
"shopping_empty_title" = "Nothing to buy yet";
"shopping_empty_message" = "Plan dishes for this week and their ingredients will appear here.";
"shopping_share_header" = "🛒 Shopping list — MealMood";
// Dish editor: photo + ingredients (2.0)
"dish_photo_label" = "Photo";
"dish_photo_add" = "Add photo";
"dish_photo_change" = "Change photo";
"dish_photo_remove" = "Remove";
"dish_ingredients_label" = "Ingredients";
"dish_ingredient_placeholder" = "e.g. 200 g spaghetti";
"dish_ingredients_add" = "Add ingredient";
@@ -397,3 +397,12 @@
"shopping_empty_title" = "Nada que comprar todavía";
"shopping_empty_message" = "Planifica platos para esta semana y sus ingredientes aparecerán aquí.";
"shopping_share_header" = "🛒 Lista de la compra — MealMood";
// Dish editor: photo + ingredients (2.0)
"dish_photo_label" = "Foto";
"dish_photo_add" = "Añadir foto";
"dish_photo_change" = "Cambiar foto";
"dish_photo_remove" = "Quitar";
"dish_ingredients_label" = "Ingredientes";
"dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis";
"dish_ingredients_add" = "Añadir ingrediente";
@@ -397,3 +397,12 @@
"shopping_empty_title" = "Rien à acheter pour l'instant";
"shopping_empty_message" = "Planifiez des plats pour cette semaine et leurs ingrédients apparaîtront ici.";
"shopping_share_header" = "🛒 Liste de courses — MealMood";
// Dish editor: photo + ingredients (2.0)
"dish_photo_label" = "Photo";
"dish_photo_add" = "Ajouter une photo";
"dish_photo_change" = "Changer la photo";
"dish_photo_remove" = "Retirer";
"dish_ingredients_label" = "Ingrédients";
"dish_ingredient_placeholder" = "ex. 200 g de spaghettis";
"dish_ingredients_add" = "Ajouter un ingrédient";
@@ -397,3 +397,12 @@
"shopping_empty_title" = "Niente da comprare per ora";
"shopping_empty_message" = "Pianifica i piatti per questa settimana e i loro ingredienti appariranno qui.";
"shopping_share_header" = "🛒 Lista della spesa — MealMood";
// Dish editor: photo + ingredients (2.0)
"dish_photo_label" = "Foto";
"dish_photo_add" = "Aggiungi foto";
"dish_photo_change" = "Cambia foto";
"dish_photo_remove" = "Rimuovi";
"dish_ingredients_label" = "Ingredienti";
"dish_ingredient_placeholder" = "es. 200 g di spaghetti";
"dish_ingredients_add" = "Aggiungi ingrediente";
@@ -397,3 +397,12 @@
"shopping_empty_title" = "Nada para comprar ainda";
"shopping_empty_message" = "Planeje pratos para esta semana e os ingredientes deles aparecerão aqui.";
"shopping_share_header" = "🛒 Lista de compras — MealMood";
// Dish editor: photo + ingredients (2.0)
"dish_photo_label" = "Foto";
"dish_photo_add" = "Adicionar foto";
"dish_photo_change" = "Trocar foto";
"dish_photo_remove" = "Remover";
"dish_ingredients_label" = "Ingredientes";
"dish_ingredient_placeholder" = "ex.: 200 g de espaguete";
"dish_ingredients_add" = "Adicionar ingrediente";
+34 -1
View File
@@ -7,6 +7,9 @@ final class DishViewModel: ObservableObject {
@Published var descriptionText: String = ""
@Published var selectedTagIds: Set<UUID> = []
@Published var isPriority: Bool = false
@Published var ingredients: [String] = []
@Published var photoData: Data?
@Published var isGeneratingIngredients: Bool = false
@Published var showTagSelector: Bool = false
@Published var showDeleteAlert: Bool = false
@Published var showAssignedDeleteAlert: Bool = false
@@ -25,6 +28,8 @@ final class DishViewModel: ObservableObject {
descriptionText = dish.descriptionText ?? ""
selectedTagIds = Set(dish.tagIds)
isPriority = dish.isPriority
ingredients = dish.ingredients
photoData = dish.photoData
}
func reset() {
@@ -33,23 +38,51 @@ final class DishViewModel: ObservableObject {
descriptionText = ""
selectedTagIds = []
isPriority = false
ingredients = []
photoData = nil
}
/// Generates ingredient lines on-device from the dish name and replaces
/// the current draft list. No-op when unavailable or the name is empty.
func generateIngredients(language: AppLanguage) {
guard !isGeneratingIngredients, isValid else { return }
isGeneratingIngredients = true
let dishName = name
Task {
let lines = await IngredientGenerator.generate(dishName: dishName, language: language)
await MainActor.run {
self.isGeneratingIngredients = false
guard !lines.isEmpty else { return }
self.ingredients = lines
PremiumAccess.recordIngredientGeneration()
AnalyticsService.logIngredientsGenerated(lineCount: lines.count, source: "dish_editor")
}
}
}
func save(context: ModelContext) {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return }
let cleanIngredients = ingredients
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if let dish = editingDish {
dish.name = trimmedName
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
dish.tagIds = Array(selectedTagIds)
dish.isPriority = isPriority
dish.ingredients = cleanIngredients
dish.photoData = photoData
} else {
let dish = Dish(
name: trimmedName,
descriptionText: descriptionText.isEmpty ? nil : descriptionText,
tagIds: Array(selectedTagIds),
isPriority: isPriority
isPriority: isPriority,
ingredients: cleanIngredients,
photoData: photoData
)
context.insert(dish)
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
+143 -1
View File
@@ -1,5 +1,6 @@
import SwiftUI
import SwiftData
import PhotosUI
struct DishFormView: View {
@Environment(\.modelContext) private var context
@@ -9,8 +10,10 @@ struct DishFormView: View {
@Query private var allSettings: [AppSettings]
@StateObject private var viewModel = DishViewModel()
@State private var showPremium = false
@State private var premiumSource: String = "dish_limit"
@State private var showDishLimitModal = false
@State private var showSavedFeedback = false
@State private var photoPickerItem: PhotosPickerItem?
var dish: Dish?
@@ -95,6 +98,111 @@ struct DishFormView: View {
)
}
// Photo section
VStack(alignment: .leading, spacing: 8) {
Text("dish_photo_label")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
if let data = viewModel.photoData, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(height: 160)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
HStack(spacing: 16) {
PhotosPicker(selection: $photoPickerItem, matching: .images) {
Label("dish_photo_change", systemImage: "photo")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
}
Button {
viewModel.photoData = nil
photoPickerItem = nil
} label: {
Label("dish_photo_remove", systemImage: "trash")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodError)
}
}
} else {
PhotosPicker(selection: $photoPickerItem, matching: .images) {
HStack {
Image(systemName: "photo.badge.plus")
Text("dish_photo_add")
}
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
}
}
}
// Ingredients section
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("dish_ingredients_label")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
Spacer()
if IngredientGenerator.isAvailable {
if viewModel.isGeneratingIngredients {
ProgressView()
} else {
Button {
requestGenerateIngredients()
} label: {
Label("shopping_generate", systemImage: "sparkles")
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor(viewModel.isValid ? .mealMoodCoral : .gray)
}
.disabled(!viewModel.isValid)
}
}
}
ForEach(viewModel.ingredients.indices, id: \.self) { index in
HStack {
TextField(
text: Binding(
get: { viewModel.ingredients.indices.contains(index) ? viewModel.ingredients[index] : "" },
set: { if viewModel.ingredients.indices.contains(index) { viewModel.ingredients[index] = $0 } }
),
prompt: Text("dish_ingredient_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6))
) {
EmptyView()
}
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.tint(.mealMoodCoral)
Button {
if viewModel.ingredients.indices.contains(index) {
viewModel.ingredients.remove(at: index)
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(12)
.background(Color.mealMoodSurface)
.cornerRadius(10)
}
Button {
viewModel.ingredients.append("")
} label: {
HStack {
Image(systemName: "plus.circle")
Text("dish_ingredients_add")
}
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
}
}
// Tags section
VStack(alignment: .leading, spacing: 8) {
Text("dish_tags_label")
@@ -216,13 +324,14 @@ struct DishFormView: View {
}
.sheet(isPresented: $showPremium) {
if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings, source: "dish_limit") }
NavigationStack { PremiumView(settings: settings, source: premiumSource) }
}
}
.sheet(isPresented: $showDishLimitModal) {
DishLimitModal(
onSeePremium: {
showDishLimitModal = false
premiumSource = "dish_limit"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
showPremium = true
}
@@ -253,9 +362,42 @@ struct DishFormView: View {
viewModel.loadDish(dish)
}
}
.onChange(of: photoPickerItem) { _, newItem in
guard let newItem else { return }
Task {
if let data = try? await newItem.loadTransferable(type: Data.self) {
viewModel.photoData = Self.downscaledJPEG(from: data)
}
}
}
}
}
private func requestGenerateIngredients() {
let isPremium = allSettings.first?.isPremium ?? false
guard PremiumAccess.canGenerateIngredients(isPremium: isPremium) else {
premiumSource = "ingredient_generation"
showPremium = true
return
}
viewModel.generateIngredients(language: language)
}
/// Downscales the picked photo (max 1024pt long edge, JPEG 0.8) so dish
/// photos stay small in the store and cheap to sync.
private static func downscaledJPEG(from data: Data, maxDimension: CGFloat = 1024) -> Data? {
guard let image = UIImage(data: data) else { return nil }
let size = image.size
let scale = min(1, maxDimension / max(size.width, size.height))
guard scale < 1 else { return image.jpegData(compressionQuality: 0.8) }
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
return resized.jpegData(compressionQuality: 0.8)
}
}
// Simple flow layout for tags
private struct DishLimitModal: View {