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:
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user