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:
@@ -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