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:
alexandrev-tibco
2026-07-22 18:16:51 +02:00
parent cbcccd66ed
commit 8e4d0c2d89
14 changed files with 442 additions and 52 deletions
+124 -9
View File
@@ -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)
}
}