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:
@@ -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