Files
FamilyMealPlanner/MealMood/Views/Dishes/DishFormView.swift
T
alexandrev-tibco 98230c732d 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
2026-07-12 17:51:33 +02:00

483 lines
22 KiB
Swift

import SwiftUI
import SwiftData
import PhotosUI
struct DishFormView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var tags: [Tag]
@Query private var dishes: [Dish]
@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?
private var language: AppLanguage {
(allSettings.first?.languageEnum ?? .system).resolved()
}
private var reachedFreeDishLimit: Bool {
guard let settings = allSettings.first else { return false }
if viewModel.isEditing { return false }
return PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: settings.isPremium)
}
var body: some View {
NavigationStack {
ZStack {
Color.mealMoodBackground.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
if showSavedFeedback {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
Text("toast_dish_saved")
}
.font(.mealMoodSmall)
.foregroundColor(.mealMoodSuccess)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(Color.mealMoodSuccess.opacity(0.1))
.cornerRadius(10)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Name field
VStack(alignment: .leading, spacing: 8) {
Text("dish_name_label")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
TextField(
text: $viewModel.name,
prompt: Text("dish_name_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6))
) {
EmptyView()
}
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.tint(.mealMoodCoral)
.padding(14)
.background(Color.mealMoodSurface)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
)
}
// Description field
VStack(alignment: .leading, spacing: 8) {
Text("dish_description_label")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
TextField(
text: $viewModel.descriptionText,
prompt: Text("dish_description_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6)),
axis: .vertical
) {
EmptyView()
}
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.tint(.mealMoodCoral)
.lineLimit(2...4)
.padding(14)
.background(Color.mealMoodSurface)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
)
}
// 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")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
if !viewModel.selectedTagIds.isEmpty {
FlowLayout(spacing: 6) {
ForEach(tags.filter { viewModel.selectedTagIds.contains($0.id) }) { tag in
HStack(spacing: 4) {
TagPill(name: tag.localizedName(language: language), color: tag.color)
Button {
viewModel.selectedTagIds.remove(tag.id)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundColor(.mealMoodTextSecondary)
}
}
}
}
}
Button {
viewModel.showTagSelector = true
} label: {
HStack {
Image(systemName: "plus.circle")
Text("dish_add_tag")
}
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
}
}
// Priority toggle
Toggle(isOn: Binding(
get: { viewModel.isPriority },
set: { viewModel.isPriority = $0 }
)) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Image(systemName: "star.fill")
.foregroundColor(.mealMoodCoral)
.font(.system(size: 13))
Text("dish_priority")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
}
Text("dish_priority_desc")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
.tint(.mealMoodCoral)
.padding(16)
.background(Color.mealMoodSurface)
.cornerRadius(14)
// Delete button (edit mode only)
if viewModel.isEditing {
Button {
viewModel.showDeleteAlert = true
} label: {
HStack {
Image(systemName: "trash")
Text("dish_delete")
}
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodError)
.padding(.vertical, 14)
.frame(maxWidth: .infinity)
.background(Color.mealMoodError.opacity(0.1))
.cornerRadius(14)
}
.padding(.top, 20)
}
}
.padding(24)
}
}
.navigationTitle(viewModel.isEditing ? String(localized: "dish_edit_title") : String(localized: "dish_new_title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(viewModel.isEditing ? "dish_cancel" : "dish_done") { dismiss() }
.foregroundColor(.mealMoodTextSecondary)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
guard !reachedFreeDishLimit else {
AnalyticsService.logDishLimitHit()
showDishLimitModal = true
HapticManager.shared.notification(type: .warning)
return
}
let wasEditing = viewModel.isEditing
viewModel.save(context: context)
if wasEditing {
dismiss()
} else {
withAnimation { showSavedFeedback = true }
HapticManager.shared.notification(type: .success)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { showSavedFeedback = false }
}
}
} label: {
Image(systemName: "checkmark")
.fontWeight(.semibold)
.foregroundColor(viewModel.isValid ? .mealMoodCoral : .gray)
}
.disabled(!viewModel.isValid)
}
}
.sheet(isPresented: $viewModel.showTagSelector) {
TagSelectorSheet(tags: tags, selectedTagIds: $viewModel.selectedTagIds)
}
.sheet(isPresented: $showPremium) {
if let settings = allSettings.first {
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
}
},
onDismiss: { showDishLimitModal = false }
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.alert("dish_delete_title", isPresented: $viewModel.showDeleteAlert) {
Button("dish_cancel", role: .cancel) {}
Button("dish_delete", role: .destructive) {
let deleted = viewModel.deleteDish(context: context)
if deleted {
dismiss()
}
}
} message: {
Text("dish_delete_message")
}
.alert("dish_delete_blocked_title", isPresented: $viewModel.showAssignedDeleteAlert) {
Button("dish_delete_blocked_ok", role: .cancel) {}
} message: {
Text("dish_delete_blocked_message")
}
.onAppear {
if let dish = 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
private struct DishLimitModal: View {
let onSeePremium: () -> Void
let onDismiss: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "sparkles")
.font(.system(size: 44))
.foregroundColor(.mealMoodCoral)
.padding(.top, 24)
VStack(spacing: 8) {
Text("dish_limit_modal_title")
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text("dish_limit_modal_message")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 10) {
PrimaryButton(title: String(localized: "dish_limit_modal_cta"), action: onSeePremium)
Button(action: onDismiss) {
Text("dish_limit_modal_dismiss")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.mealMoodBackground.ignoresSafeArea())
}
}
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrange(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrange(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
}
}
private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var maxHeight: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
positions.append(CGPoint(x: x, y: y))
rowHeight = max(rowHeight, size.height)
x += size.width + spacing
maxHeight = max(maxHeight, y + rowHeight)
}
return (CGSize(width: maxWidth, height: maxHeight), positions)
}
}