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
+8
View File
@@ -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 */,
+5 -1
View File
@@ -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ã";
+61
View File
@@ -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)
}
}
+20
View File
@@ -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 }
+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)
}
}
+45 -33
View File
@@ -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) {
+8 -9
View File
@@ -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 {