2.0: dictado de platos/ingredientes + calendario más alto en iPad/Mac
- Dictado por voz (Speech/SFSpeechRecognizer, on-device) en el nombre de plato y en ingredientes, con transcripción en vivo. - Un solo texto dictado se separa en ingredientes individuales vía Apple Foundation Models on-device (IngredientParser), con fallback heurístico. - Permisos de micrófono y reconocimiento de voz en Info.plist. - Cadenas de dictado en los 6 idiomas. - iPad/Mac: el calendario reclama ~50% de la altura disponible en lugar de quedar fijo a ~240px; iPhone (compact) sin cambios. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A3HaWmmtTQ1vdTERtSYU6p
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
|
||||
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientParser.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechDictationService.swift; sourceTree = "<group>"; };
|
||||
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; };
|
||||
76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = "<group>"; };
|
||||
797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>58</string>
|
||||
<string>59</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9985112590</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -43,6 +43,10 @@
|
||||
<string>MealMood needs calendar access to sync your meal plan.</string>
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>MealMood needs calendar access to create meal events.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>MealMood uses the microphone so you can dictate dish names and ingredients instead of typing.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>MealMood uses speech recognition to turn your dictation into dish names and ingredient lists.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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ã";
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user