diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index ff000ec..d74f6ff 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -79,6 +79,8 @@ CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; }; D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C00EED19123F00AE87FF24 /* AppSettings.swift */; }; D39BED40CA59849F181D80B5 /* IngredientGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */; }; + A1B2C3D4E5F60718293A4B01 /* IngredientParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */; }; + A1B2C3D4E5F60718293A4B03 /* SpeechDictationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */; }; D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */; }; D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; }; DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */; }; @@ -161,6 +163,8 @@ 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = ""; }; 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientParser.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechDictationService.swift; sourceTree = ""; }; 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = ""; }; 76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = ""; }; 797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = ""; }; @@ -520,6 +524,8 @@ 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */, A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */, 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */, + A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */, + A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */, 5C512046B1410E236A3F6286 /* ShoppingListService.swift */, A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */, ); @@ -761,6 +767,8 @@ B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */, 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */, D39BED40CA59849F181D80B5 /* IngredientGenerator.swift in Sources */, + A1B2C3D4E5F60718293A4B01 /* IngredientParser.swift in Sources */, + A1B2C3D4E5F60718293A4B03 /* SpeechDictationService.swift in Sources */, B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */, 44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */, 57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */, diff --git a/MealMood/Resources/Info.plist b/MealMood/Resources/Info.plist index 6ec645b..6e6bdbe 100644 --- a/MealMood/Resources/Info.plist +++ b/MealMood/Resources/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 58 + 59 GADApplicationIdentifier ca-app-pub-1549720748100858~9985112590 ITSAppUsesNonExemptEncryption @@ -43,6 +43,10 @@ MealMood needs calendar access to sync your meal plan. NSCalendarsWriteOnlyAccessUsageDescription MealMood needs calendar access to create meal events. + NSMicrophoneUsageDescription + MealMood uses the microphone so you can dictate dish names and ingredients instead of typing. + NSSpeechRecognitionUsageDescription + MealMood uses speech recognition to turn your dictation into dish names and ingredient lists. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 92a4cc4..85e6434 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -406,6 +406,14 @@ "dish_ingredients_label" = "Zutaten"; "dish_ingredient_placeholder" = "z. B. 200 g Spaghetti"; "dish_ingredients_add" = "Zutat hinzufügen"; +"dish_dictate" = "Diktieren"; +"dish_ingredients_dictate" = "Diktieren"; +"dictation_listening" = "Höre zu…"; +"dictation_stop" = "Fertig"; +"dictation_ingredients_hint" = "Nenne alle Zutaten – ich teile sie in eine Liste auf."; +"dictation_denied_title" = "Mikrofonzugriff erforderlich"; +"dictation_denied_message" = "Aktiviere Mikrofon und Spracherkennung für MealMood in den Einstellungen, um zu diktieren."; +"dictation_open_settings" = "Einstellungen öffnen"; // Meal types (2.0) "breakfast" = "Frühstück"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index c86b2aa..25f3814 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -406,6 +406,14 @@ "dish_ingredients_label" = "Ingredients"; "dish_ingredient_placeholder" = "e.g. 200 g spaghetti"; "dish_ingredients_add" = "Add ingredient"; +"dish_dictate" = "Dictate"; +"dish_ingredients_dictate" = "Dictate"; +"dictation_listening" = "Listening…"; +"dictation_stop" = "Done"; +"dictation_ingredients_hint" = "Say all the ingredients — I'll split them into a list."; +"dictation_denied_title" = "Microphone access needed"; +"dictation_denied_message" = "Turn on Microphone and Speech Recognition for MealMood in Settings to dictate."; +"dictation_open_settings" = "Open Settings"; // Meal types (2.0) "breakfast" = "Breakfast"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 5c4a878..1bc1a21 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -406,6 +406,14 @@ "dish_ingredients_label" = "Ingredientes"; "dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis"; "dish_ingredients_add" = "Añadir ingrediente"; +"dish_dictate" = "Dictar"; +"dish_ingredients_dictate" = "Dictar"; +"dictation_listening" = "Escuchando…"; +"dictation_stop" = "Listo"; +"dictation_ingredients_hint" = "Di todos los ingredientes: los separo en una lista."; +"dictation_denied_title" = "Se necesita acceso al micrófono"; +"dictation_denied_message" = "Activa Micrófono y Reconocimiento de voz para MealMood en Ajustes para dictar."; +"dictation_open_settings" = "Abrir Ajustes"; // Meal types (2.0) "breakfast" = "Desayuno"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 439b1c7..da00be8 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -406,6 +406,14 @@ "dish_ingredients_label" = "Ingrédients"; "dish_ingredient_placeholder" = "ex. 200 g de spaghettis"; "dish_ingredients_add" = "Ajouter un ingrédient"; +"dish_dictate" = "Dicter"; +"dish_ingredients_dictate" = "Dicter"; +"dictation_listening" = "Écoute…"; +"dictation_stop" = "Terminé"; +"dictation_ingredients_hint" = "Dites tous les ingrédients : je les répartis en liste."; +"dictation_denied_title" = "Accès au micro requis"; +"dictation_denied_message" = "Activez le Micro et la Reconnaissance vocale pour MealMood dans Réglages pour dicter."; +"dictation_open_settings" = "Ouvrir Réglages"; // Meal types (2.0) "breakfast" = "Petit-déjeuner"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 547997f..12a9ce4 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -406,6 +406,14 @@ "dish_ingredients_label" = "Ingredienti"; "dish_ingredient_placeholder" = "es. 200 g di spaghetti"; "dish_ingredients_add" = "Aggiungi ingrediente"; +"dish_dictate" = "Detta"; +"dish_ingredients_dictate" = "Detta"; +"dictation_listening" = "In ascolto…"; +"dictation_stop" = "Fatto"; +"dictation_ingredients_hint" = "Di' tutti gli ingredienti: li divido in un elenco."; +"dictation_denied_title" = "Serve l'accesso al microfono"; +"dictation_denied_message" = "Attiva Microfono e Riconoscimento vocale per MealMood in Impostazioni per dettare."; +"dictation_open_settings" = "Apri Impostazioni"; // Meal types (2.0) "breakfast" = "Colazione"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index ace4b8d..c893fa9 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -406,6 +406,14 @@ "dish_ingredients_label" = "Ingredientes"; "dish_ingredient_placeholder" = "ex.: 200 g de espaguete"; "dish_ingredients_add" = "Adicionar ingrediente"; +"dish_dictate" = "Ditar"; +"dish_ingredients_dictate" = "Ditar"; +"dictation_listening" = "Ouvindo…"; +"dictation_stop" = "Pronto"; +"dictation_ingredients_hint" = "Fale todos os ingredientes: eu separo em uma lista."; +"dictation_denied_title" = "Acesso ao microfone necessário"; +"dictation_denied_message" = "Ative Microfone e Reconhecimento de fala para o MealMood nos Ajustes para ditar."; +"dictation_open_settings" = "Abrir Ajustes"; // Meal types (2.0) "breakfast" = "Café da manhã"; diff --git a/MealMood/Services/IngredientParser.swift b/MealMood/Services/IngredientParser.swift new file mode 100644 index 0000000..f6ab985 --- /dev/null +++ b/MealMood/Services/IngredientParser.swift @@ -0,0 +1,61 @@ +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Splits a single free-form phrase (typically dictated, e.g. "two onions, half +/// a litre of milk and some olive oil") into individual, normalized ingredient +/// lines. Uses Apple's on-device Foundation Models when available for smart +/// parsing of quantities and natural-language separators; otherwise falls back +/// to a lightweight heuristic splitter. Fully local and never throws. +enum IngredientParser { + + /// Parses `text` into clean ingredient lines, kept in `language`. + static func parse(_ text: String, language: AppLanguage) async -> [String] { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + #if canImport(FoundationModels) + if #available(iOS 26.0, *), case .available = SystemLanguageModel.default.availability { + let langName = language.resolved().displayName + let instructions = """ + You split a spoken grocery list into individual ingredients. The user \ + dictates several ingredients in one sentence, separated by commas, the \ + word "and", or natural pauses. Output ONE ingredient per line, keeping \ + any quantity the user actually said (e.g. "2 onions", "500 g flour"). \ + Do not invent ingredients, do not add quantities that were not said, \ + do not number or bullet the lines, and add no commentary. Keep the \ + ingredients written in \(langName). + """ + do { + let session = LanguageModelSession(instructions: instructions) + let response = try await session.respond(to: trimmed) + let lines = IngredientGenerator.parseLines(response.content) + if !lines.isEmpty { return lines } + } catch { + CrashlyticsService.record(error, context: "ingredient_parsing") + } + } + #endif + + return heuristicSplit(trimmed) + } + + /// Splits on newlines, commas, semicolons and standalone conjunctions + /// ("and"/"y"/"e"/"et"/"und") when the on-device model is unavailable. + static func heuristicSplit(_ text: String) -> [String] { + let separators = CharacterSet(charactersIn: ",;\n•") + let conjunctions = [" y ", " e ", " and ", " et ", " und "] + return text + .components(separatedBy: separators) + .flatMap { piece -> [String] in + var parts = [piece] + for conjunction in conjunctions { + parts = parts.flatMap { $0.components(separatedBy: conjunction) } + } + return parts + } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } +} diff --git a/MealMood/Services/SpeechDictationService.swift b/MealMood/Services/SpeechDictationService.swift new file mode 100644 index 0000000..b965db4 --- /dev/null +++ b/MealMood/Services/SpeechDictationService.swift @@ -0,0 +1,123 @@ +import Foundation +import Speech +import AVFoundation + +/// Live dictation wrapper around `SFSpeechRecognizer` + `AVAudioEngine`. It +/// publishes an incrementally-updated `transcript` while recording so users can +/// speak a dish name — or a whole list of ingredients in one go — instead of +/// typing. Prefers on-device recognition when the locale supports it (private, +/// offline); otherwise falls back to Apple's server recognition. +@MainActor +final class SpeechDictationService: ObservableObject { + enum State: Equatable { + case idle + case recording + case denied // microphone or speech-recognition permission refused + case unavailable // no recognizer available for the requested locale + } + + @Published private(set) var state: State = .idle + @Published private(set) var transcript: String = "" + + private let audioEngine = AVAudioEngine() + private var recognizer: SFSpeechRecognizer? + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + + var isRecording: Bool { state == .recording } + + /// Requests permissions and, on success, starts live transcription in the + /// given locale. Partial results stream into `transcript`. + func start(localeIdentifier: String) { + guard state != .recording else { return } + transcript = "" + + SFSpeechRecognizer.requestAuthorization { [weak self] speechAuth in + guard let self else { return } + Task { @MainActor in + guard speechAuth == .authorized else { self.state = .denied; return } + self.requestMicAndStart(localeIdentifier: localeIdentifier) + } + } + } + + private func requestMicAndStart(localeIdentifier: String) { + AVAudioApplication.requestRecordPermission { [weak self] granted in + guard let self else { return } + Task { @MainActor in + guard granted else { self.state = .denied; return } + self.beginSession(localeIdentifier: localeIdentifier) + } + } + } + + private func beginSession(localeIdentifier: String) { + guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)), + recognizer.isAvailable else { + state = .unavailable + return + } + self.recognizer = recognizer + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: .duckOthers) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + self.request = request + + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.request?.append(buffer) + } + + audioEngine.prepare() + try audioEngine.start() + state = .recording + + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + Task { @MainActor in + if let result { + self.transcript = result.bestTranscription.formattedString + } + if error != nil || (result?.isFinal ?? false) { + self.teardownAudio() + if self.state == .recording { self.state = .idle } + } + } + } + } catch { + CrashlyticsService.record(error, context: "speech_dictation_start") + teardownAudio() + state = .idle + } + } + + /// Stops capture, keeping the last transcript, and returns the final text. + @discardableResult + func stop() -> String { + teardownAudio() + if state == .recording { state = .idle } + return transcript + } + + private func teardownAudio() { + if audioEngine.isRunning { + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + } + request?.endAudio() + task?.cancel() + request = nil + task = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } +} diff --git a/MealMood/ViewModels/DishViewModel.swift b/MealMood/ViewModels/DishViewModel.swift index 9bd551a..f9e1ec5 100644 --- a/MealMood/ViewModels/DishViewModel.swift +++ b/MealMood/ViewModels/DishViewModel.swift @@ -10,6 +10,7 @@ final class DishViewModel: ObservableObject { @Published var ingredients: [String] = [] @Published var photoData: Data? @Published var isGeneratingIngredients: Bool = false + @Published var isParsingIngredients: Bool = false @Published var showTagSelector: Bool = false @Published var showDeleteAlert: Bool = false @Published var showAssignedDeleteAlert: Bool = false @@ -60,6 +61,25 @@ final class DishViewModel: ObservableObject { } } + /// Splits a dictated phrase into individual ingredient lines and appends + /// them to the current draft, dropping any empty placeholder rows first. + /// No-op on empty input or while a parse is already running. + func addDictatedIngredients(_ text: String, language: AppLanguage) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isParsingIngredients else { return } + isParsingIngredients = true + Task { + let lines = await IngredientParser.parse(trimmed, language: language) + await MainActor.run { + self.isParsingIngredients = false + guard !lines.isEmpty else { return } + self.ingredients.removeAll { $0.trimmingCharacters(in: .whitespaces).isEmpty } + self.ingredients.append(contentsOf: lines) + AnalyticsService.logIngredientsGenerated(lineCount: lines.count, source: "dictation") + } + } + } + func save(context: ModelContext) { let trimmedName = name.trimmingCharacters(in: .whitespaces) guard !trimmedName.isEmpty else { return } diff --git a/MealMood/Views/Dishes/DishFormView.swift b/MealMood/Views/Dishes/DishFormView.swift index ea38876..fc30b32 100644 --- a/MealMood/Views/Dishes/DishFormView.swift +++ b/MealMood/Views/Dishes/DishFormView.swift @@ -15,6 +15,12 @@ struct DishFormView: View { @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 { @@ -54,15 +60,19 @@ struct DishFormView: View { .font(.mealMoodSmall) .foregroundColor(.mealMoodTextSecondary) - TextField( - text: $viewModel.name, - prompt: Text("dish_name_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6)) - ) { - EmptyView() + 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) } - .font(.mealMoodBody) - .foregroundColor(.mealMoodTextPrimary) - .tint(.mealMoodCoral) .padding(14) .background(Color.mealMoodSurface) .cornerRadius(12) @@ -140,11 +150,25 @@ struct DishFormView: View { // Ingredients section VStack(alignment: .leading, spacing: 8) { - HStack { + 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() @@ -161,6 +185,10 @@ struct DishFormView: View { } } + if dictation.isRecording && dictationTarget == .ingredients { + ingredientDictationBubble + } + ForEach(viewModel.ingredients.indices, id: \.self) { index in HStack { TextField( @@ -370,6 +398,93 @@ struct DishFormView: View { } } } + .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) } } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 15efb7c..e7d6b7c 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -44,6 +44,16 @@ struct HomeView: View { private var isRunningOnMac: Bool { ProcessInfo.processInfo.isiOSAppOnMac } private var contentHorizontalPadding: CGFloat { (isRunningOnMac || horizontalSizeClass == .regular) ? 14 : 0 } + /// On iPad/Mac (regular width) let the calendar claim roughly half of the + /// available height so it feels like the centerpiece instead of a strip at + /// the top. On iPhone (compact) return nil to keep the intrinsic sizing. + private func calendarHeight(availableHeight: CGFloat) -> CGFloat? { + guard horizontalSizeClass == .regular, availableHeight > 0 else { return nil } + let target = availableHeight * 0.5 + // Keep at least ~260pt for the dish drawer below. + return min(max(target, 300), availableHeight - 260) + } + var body: some View { NavigationStack { ZStack { @@ -249,45 +259,47 @@ struct HomeView: View { .padding(.bottom, 8) } - VStack(spacing: 10) { - WeekCalendarView( - plan: plan, - settings: settings, - dishes: dishes, - tags: tags, - viewModel: viewModel, - onTapEmptySlot: { slot in - selectedEmptySlotId = slot.id - }, - onTapFilledSlot: { slot in - selectedFilledSlotId = slot.id + GeometryReader { contentGeo in + VStack(spacing: 10) { + WeekCalendarView( + plan: plan, + settings: settings, + dishes: dishes, + tags: tags, + viewModel: viewModel, + onTapEmptySlot: { slot in + selectedEmptySlotId = slot.id + }, + onTapFilledSlot: { slot in + selectedFilledSlotId = slot.id + } + ) + .frame(height: calendarHeight(availableHeight: contentGeo.size.height)) + + let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut }.count + if filledCount > 0 { + exportCallout(plan: plan, settings: settings) } - ) - let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut }.count - if filledCount > 0 { - exportCallout(plan: plan, settings: settings) - } + if !viewModel.canEditCurrentWeek && filledCount > 0 && settings.isPremium { + weekRatingRow(plan: plan) + } - if !viewModel.canEditCurrentWeek && filledCount > 0 && settings.isPremium { - weekRatingRow(plan: plan) - } + let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }.count + if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 { + autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings) + } - let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }.count - if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 { - autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings) + ScrollView(showsIndicators: true) { + dishDrawer(plan: plan, settings: settings) + .padding(.bottom, 0) + } + .frame(maxHeight: .infinity) } - - ScrollView(showsIndicators: true) { - dishDrawer(plan: plan, settings: settings) - .padding(.bottom, 0) - } - .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity, minHeight: contentGeo.size.height, alignment: .top) + .padding(.horizontal, contentHorizontalPadding) + .padding(.bottom, 0) } - .frame(maxWidth: .infinity) - .frame(maxWidth: .infinity, alignment: .top) - .padding(.horizontal, contentHorizontalPadding) - .padding(.bottom, 0) } .frame(maxHeight: .infinity, alignment: .top) .safeAreaInset(edge: .bottom, spacing: 0) { diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 98e93c4..e9bbc18 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -95,16 +95,16 @@ struct WeekCalendarView: View { let spacing: CGFloat = 10 let rowLabelWidth: CGFloat = 86 let headerHeight: CGFloat = 26 - let slotRowHeight: CGFloat = 96 let verticalInset: CGFloat = 2 + let rowCount = CGFloat(max(mealTypes.count, 1)) + let interRowDividers = CGFloat(max(0, mealTypes.count - 1)) let contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing) let dayWidth = max(92, contentWidth / max(dayCount, 1)) - let totalHeight = - (verticalInset * 2) + - headerHeight + - 1 + - (CGFloat(mealTypes.count) * slotRowHeight) + - CGFloat(max(0, mealTypes.count - 1)) + // Distribute the available height across the meal rows so the calendar + // grows to fill its container on iPad/Mac instead of staying tiny. + let chromeHeight = (verticalInset * 2) + headerHeight + 1 + interRowDividers + let availableForRows = proxy.size.height - chromeHeight + let slotRowHeight = max(96, availableForRows / rowCount) VStack(spacing: 0) { HStack(spacing: 0) { @@ -144,9 +144,8 @@ struct WeekCalendarView: View { } .padding(.horizontal, 16) .padding(.vertical, verticalInset) - .frame(height: totalHeight, alignment: .top) + .frame(height: proxy.size.height, alignment: .top) } - .frame(height: CGFloat(mealTypes.count) * 98 + 44) } private func calendarDivider(height: CGFloat? = nil) -> some View {