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_title" = "Noch nichts einzukaufen";
|
||||||
"shopping_empty_message" = "Plane Gerichte für diese Woche und ihre Zutaten erscheinen hier.";
|
"shopping_empty_message" = "Plane Gerichte für diese Woche und ihre Zutaten erscheinen hier.";
|
||||||
"shopping_share_header" = "🛒 Einkaufsliste — MealMood";
|
"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_title" = "Nothing to buy yet";
|
||||||
"shopping_empty_message" = "Plan dishes for this week and their ingredients will appear here.";
|
"shopping_empty_message" = "Plan dishes for this week and their ingredients will appear here.";
|
||||||
"shopping_share_header" = "🛒 Shopping list — MealMood";
|
"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_title" = "Nada que comprar todavía";
|
||||||
"shopping_empty_message" = "Planifica platos para esta semana y sus ingredientes aparecerán aquí.";
|
"shopping_empty_message" = "Planifica platos para esta semana y sus ingredientes aparecerán aquí.";
|
||||||
"shopping_share_header" = "🛒 Lista de la compra — MealMood";
|
"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_title" = "Rien à acheter pour l'instant";
|
||||||
"shopping_empty_message" = "Planifiez des plats pour cette semaine et leurs ingrédients apparaîtront ici.";
|
"shopping_empty_message" = "Planifiez des plats pour cette semaine et leurs ingrédients apparaîtront ici.";
|
||||||
"shopping_share_header" = "🛒 Liste de courses — MealMood";
|
"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_title" = "Niente da comprare per ora";
|
||||||
"shopping_empty_message" = "Pianifica i piatti per questa settimana e i loro ingredienti appariranno qui.";
|
"shopping_empty_message" = "Pianifica i piatti per questa settimana e i loro ingredienti appariranno qui.";
|
||||||
"shopping_share_header" = "🛒 Lista della spesa — MealMood";
|
"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_title" = "Nada para comprar ainda";
|
||||||
"shopping_empty_message" = "Planeje pratos para esta semana e os ingredientes deles aparecerão aqui.";
|
"shopping_empty_message" = "Planeje pratos para esta semana e os ingredientes deles aparecerão aqui.";
|
||||||
"shopping_share_header" = "🛒 Lista de compras — MealMood";
|
"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 descriptionText: String = ""
|
||||||
@Published var selectedTagIds: Set<UUID> = []
|
@Published var selectedTagIds: Set<UUID> = []
|
||||||
@Published var isPriority: Bool = false
|
@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 showTagSelector: Bool = false
|
||||||
@Published var showDeleteAlert: Bool = false
|
@Published var showDeleteAlert: Bool = false
|
||||||
@Published var showAssignedDeleteAlert: Bool = false
|
@Published var showAssignedDeleteAlert: Bool = false
|
||||||
@@ -25,6 +28,8 @@ final class DishViewModel: ObservableObject {
|
|||||||
descriptionText = dish.descriptionText ?? ""
|
descriptionText = dish.descriptionText ?? ""
|
||||||
selectedTagIds = Set(dish.tagIds)
|
selectedTagIds = Set(dish.tagIds)
|
||||||
isPriority = dish.isPriority
|
isPriority = dish.isPriority
|
||||||
|
ingredients = dish.ingredients
|
||||||
|
photoData = dish.photoData
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
@@ -33,23 +38,51 @@ final class DishViewModel: ObservableObject {
|
|||||||
descriptionText = ""
|
descriptionText = ""
|
||||||
selectedTagIds = []
|
selectedTagIds = []
|
||||||
isPriority = false
|
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) {
|
func save(context: ModelContext) {
|
||||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
guard !trimmedName.isEmpty else { return }
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
|
||||||
|
let cleanIngredients = ingredients
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
if let dish = editingDish {
|
if let dish = editingDish {
|
||||||
dish.name = trimmedName
|
dish.name = trimmedName
|
||||||
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
|
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
|
||||||
dish.tagIds = Array(selectedTagIds)
|
dish.tagIds = Array(selectedTagIds)
|
||||||
dish.isPriority = isPriority
|
dish.isPriority = isPriority
|
||||||
|
dish.ingredients = cleanIngredients
|
||||||
|
dish.photoData = photoData
|
||||||
} else {
|
} else {
|
||||||
let dish = Dish(
|
let dish = Dish(
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
descriptionText: descriptionText.isEmpty ? nil : descriptionText,
|
descriptionText: descriptionText.isEmpty ? nil : descriptionText,
|
||||||
tagIds: Array(selectedTagIds),
|
tagIds: Array(selectedTagIds),
|
||||||
isPriority: isPriority
|
isPriority: isPriority,
|
||||||
|
ingredients: cleanIngredients,
|
||||||
|
photoData: photoData
|
||||||
)
|
)
|
||||||
context.insert(dish)
|
context.insert(dish)
|
||||||
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
|
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct DishFormView: View {
|
struct DishFormView: View {
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@@ -9,8 +10,10 @@ struct DishFormView: View {
|
|||||||
@Query private var allSettings: [AppSettings]
|
@Query private var allSettings: [AppSettings]
|
||||||
@StateObject private var viewModel = DishViewModel()
|
@StateObject private var viewModel = DishViewModel()
|
||||||
@State private var showPremium = false
|
@State private var showPremium = false
|
||||||
|
@State private var premiumSource: String = "dish_limit"
|
||||||
@State private var showDishLimitModal = false
|
@State private var showDishLimitModal = false
|
||||||
@State private var showSavedFeedback = false
|
@State private var showSavedFeedback = false
|
||||||
|
@State private var photoPickerItem: PhotosPickerItem?
|
||||||
|
|
||||||
var dish: Dish?
|
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
|
// Tags section
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("dish_tags_label")
|
Text("dish_tags_label")
|
||||||
@@ -216,13 +324,14 @@ struct DishFormView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showPremium) {
|
.sheet(isPresented: $showPremium) {
|
||||||
if let settings = allSettings.first {
|
if let settings = allSettings.first {
|
||||||
NavigationStack { PremiumView(settings: settings, source: "dish_limit") }
|
NavigationStack { PremiumView(settings: settings, source: premiumSource) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showDishLimitModal) {
|
.sheet(isPresented: $showDishLimitModal) {
|
||||||
DishLimitModal(
|
DishLimitModal(
|
||||||
onSeePremium: {
|
onSeePremium: {
|
||||||
showDishLimitModal = false
|
showDishLimitModal = false
|
||||||
|
premiumSource = "dish_limit"
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||||
showPremium = true
|
showPremium = true
|
||||||
}
|
}
|
||||||
@@ -253,9 +362,42 @@ struct DishFormView: View {
|
|||||||
viewModel.loadDish(dish)
|
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
|
// Simple flow layout for tags
|
||||||
private struct DishLimitModal: View {
|
private struct DishLimitModal: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user