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? @StateObject private var dictation = SpeechDictationService() @State private var dictationTarget: DictationTarget? @State private var showDictationDeniedAlert = false private enum DictationTarget { case name, ingredients } 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) HStack(spacing: 8) { TextField( text: $viewModel.name, prompt: Text("dish_name_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6)) ) { EmptyView() } .font(.mealMoodBody) .foregroundColor(.mealMoodTextPrimary) .tint(.mealMoodCoral) micButton(target: .name) } .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(spacing: 14) { Text("dish_ingredients_label") .font(.mealMoodSmall) .foregroundColor(.mealMoodTextSecondary) Spacer() if viewModel.isParsingIngredients || (dictation.isRecording && dictationTarget == .ingredients) { // stop/parse handled by the live bubble below if viewModel.isParsingIngredients { ProgressView() } } else { Button { toggleDictation(.ingredients) } label: { Label("dish_ingredients_dictate", systemImage: "mic.fill") .font(.mealMoodSmall.weight(.semibold)) .foregroundColor(.mealMoodCoral) } } 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) } } } if dictation.isRecording && dictationTarget == .ingredients { ingredientDictationBubble } 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) } } } .onChange(of: dictation.transcript) { _, newValue in guard dictation.isRecording, dictationTarget == .name else { return } viewModel.name = newValue } .onChange(of: dictation.state) { _, newState in if newState == .denied || newState == .unavailable { dictationTarget = nil if newState == .denied { showDictationDeniedAlert = true } } } .onDisappear { dictation.stop() } .alert("dictation_denied_title", isPresented: $showDictationDeniedAlert) { Button("dish_cancel", role: .cancel) {} Button("dictation_open_settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } message: { Text("dictation_denied_message") } } } // MARK: - Dictation /// A microphone toggle that dictates into `target`. Shows an active waveform /// while this field is recording. @ViewBuilder private func micButton(target: DictationTarget) -> some View { let isActive = dictation.isRecording && dictationTarget == target Button { toggleDictation(target) } label: { Image(systemName: isActive ? "waveform" : "mic.fill") .font(.system(size: 16, weight: .semibold)) .foregroundColor(isActive ? .mealMoodError : .mealMoodCoral) .symbolEffect(.variableColor.iterative, isActive: isActive) .frame(width: 28, height: 28) } .accessibilityLabel(Text("dish_dictate")) } /// Live transcript shown while dictating a full ingredient list, with a /// button to stop and split the phrase into individual ingredients. private var ingredientDictationBubble: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: "waveform") .foregroundColor(.mealMoodError) .symbolEffect(.variableColor.iterative, isActive: true) Text("dictation_listening") .font(.mealMoodSmall.weight(.semibold)) .foregroundColor(.mealMoodTextSecondary) Spacer() Button { toggleDictation(.ingredients) } label: { Text("dictation_stop") .font(.mealMoodSmall.weight(.semibold)) .foregroundColor(.mealMoodCoral) } } Text(dictation.transcript.isEmpty ? String(localized: "dictation_ingredients_hint") : dictation.transcript) .font(.mealMoodBody) .foregroundColor(dictation.transcript.isEmpty ? .mealMoodTextSecondary : .mealMoodTextPrimary) .frame(maxWidth: .infinity, alignment: .leading) } .padding(14) .background(Color.mealMoodCoral.opacity(0.08)) .cornerRadius(12) } /// Starts dictation for `target`, or stops the current one — routing the /// final text to the name field or the ingredient parser. private func toggleDictation(_ target: DictationTarget) { if dictation.isRecording { let text = dictation.stop() let finishedTarget = dictationTarget dictationTarget = nil if finishedTarget == .ingredients { viewModel.addDictatedIngredients(text, language: language) } } else { dictationTarget = target dictation.start(localeIdentifier: language.localeIdentifier) } } 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) } }