diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index a7706da..32f5e4c 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index d0417c2..53986aa 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 42766c7..4273faf 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 090ff15..e272e7b 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 58b0823..5bf768f 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 46aa7e3..282db71 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/ViewModels/DishViewModel.swift b/MealMood/ViewModels/DishViewModel.swift index 0e60188..96f05a6 100644 --- a/MealMood/ViewModels/DishViewModel.swift +++ b/MealMood/ViewModels/DishViewModel.swift @@ -7,6 +7,9 @@ final class DishViewModel: ObservableObject { @Published var descriptionText: String = "" @Published var selectedTagIds: Set = [] @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) diff --git a/MealMood/Views/Dishes/DishFormView.swift b/MealMood/Views/Dishes/DishFormView.swift index 240b412..ea38876 100644 --- a/MealMood/Views/Dishes/DishFormView.swift +++ b/MealMood/Views/Dishes/DishFormView.swift @@ -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,8 +362,41 @@ 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