diff --git a/MealMood/Models/AppSettings.swift b/MealMood/Models/AppSettings.swift index 59c2acf..958d7eb 100644 --- a/MealMood/Models/AppSettings.swift +++ b/MealMood/Models/AppSettings.swift @@ -7,11 +7,17 @@ final class AppSettings { var includeWeekends: Bool = true var language: String // "spanish", "english" + /// Comma-separated MealType raw values, in chronological order (2.0). + /// nil = derive from the legacy `mealWindows` field (pre-2.0 installs). + var enabledMealTypesRaw: String? + var calendarId: String? var syncEnabled: Bool = false var syncMode: String? // "weekComplete", "manual" var lunchTime: Date var dinnerTime: Date + var breakfastTime: Date? // optional: pre-2.0 stores lack it + var snackTime: Date? var eventDuration: Int var eventPrefix: String var reminderMinutesBefore: Int? @@ -71,6 +77,57 @@ final class AppSettings { get { WeekExportStyle(rawValue: weekExportStyle ?? "") ?? .defaultStyle } set { weekExportStyle = newValue.rawValue } } + + /// The meal types the user plans, in chronological order. Single source of + /// truth for slot generation, calendar rows, exports and the widget. + /// Falls back to the legacy `mealWindows` for pre-2.0 installs. + var activeMealTypes: [MealType] { + get { + if let raw = enabledMealTypesRaw { + let types = raw.split(separator: ",").compactMap { MealType(rawValue: String($0)) } + if !types.isEmpty { + return MealType.allCases.filter { types.contains($0) } + } + } + switch mealWindowsEnum { + case .dinnerOnly: return [.dinner] + case .lunchOnly: return [.lunch] + case .both: return [.lunch, .dinner] + } + } + set { + let ordered = MealType.allCases.filter { newValue.contains($0) } + enabledMealTypesRaw = ordered.isEmpty + ? MealType.dinner.rawValue + : ordered.map(\.rawValue).joined(separator: ",") + // Keep the legacy field roughly coherent for pre-2.0 code paths. + if ordered.contains(.lunch) && ordered.contains(.dinner) { + mealWindowsEnum = .both + } else if ordered.contains(.lunch) { + mealWindowsEnum = .lunchOnly + } else { + mealWindowsEnum = .dinnerOnly + } + } + } + + /// Event start time for a meal type (calendar sync). Breakfast/snack get + /// sensible defaults on pre-2.0 stores that never set them. + func time(for mealType: MealType) -> Date { + switch mealType { + case .breakfast: return breakfastTime ?? Self.defaultTime(hour: 8) + case .lunch: return lunchTime + case .snack: return snackTime ?? Self.defaultTime(hour: 17) + case .dinner: return dinnerTime + } + } + + static func defaultTime(hour: Int) -> Date { + var components = DateComponents() + components.hour = hour + components.minute = 0 + return Calendar.current.date(from: components) ?? Date() + } } enum MealWindows: String, CaseIterable { @@ -142,15 +199,34 @@ enum AppLanguage: String, CaseIterable { } enum MealType: String, Codable, CaseIterable { + // Declaration order is chronological — allCases drives row/slot ordering. + case breakfast case lunch + case snack case dinner var icon: String { switch self { + case .breakfast: return "cup.and.saucer.fill" case .lunch: return "sun.max.fill" + case .snack: return "carrot.fill" case .dinner: return "moon.stars.fill" } } + + var localizedKey: String { + switch self { + case .breakfast: return "breakfast" + case .lunch: return "lunch" + case .snack: return "snack" + case .dinner: return "dinner" + } + } + + /// Chronological position, used to order slots within a day. + var order: Int { + MealType.allCases.firstIndex(of: self) ?? 0 + } } enum CalendarSyncMode: String, CaseIterable { diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 32f5e4c..81bdd7b 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -406,3 +406,7 @@ "dish_ingredients_label" = "Zutaten"; "dish_ingredient_placeholder" = "z. B. 200 g Spaghetti"; "dish_ingredients_add" = "Zutat hinzufügen"; + +// Meal types (2.0) +"breakfast" = "Frühstück"; +"snack" = "Snack"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 53986aa..d1cea27 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -406,3 +406,7 @@ "dish_ingredients_label" = "Ingredients"; "dish_ingredient_placeholder" = "e.g. 200 g spaghetti"; "dish_ingredients_add" = "Add ingredient"; + +// Meal types (2.0) +"breakfast" = "Breakfast"; +"snack" = "Snack"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 4273faf..ec88623 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -406,3 +406,7 @@ "dish_ingredients_label" = "Ingredientes"; "dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis"; "dish_ingredients_add" = "Añadir ingrediente"; + +// Meal types (2.0) +"breakfast" = "Desayuno"; +"snack" = "Merienda"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index e272e7b..49ab09f 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -406,3 +406,7 @@ "dish_ingredients_label" = "Ingrédients"; "dish_ingredient_placeholder" = "ex. 200 g de spaghettis"; "dish_ingredients_add" = "Ajouter un ingrédient"; + +// Meal types (2.0) +"breakfast" = "Petit-déjeuner"; +"snack" = "Goûter"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 5bf768f..99fdccb 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -406,3 +406,7 @@ "dish_ingredients_label" = "Ingredienti"; "dish_ingredient_placeholder" = "es. 200 g di spaghetti"; "dish_ingredients_add" = "Aggiungi ingrediente"; + +// Meal types (2.0) +"breakfast" = "Colazione"; +"snack" = "Merenda"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 282db71..6d3938d 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -406,3 +406,7 @@ "dish_ingredients_label" = "Ingredientes"; "dish_ingredient_placeholder" = "ex.: 200 g de espaguete"; "dish_ingredients_add" = "Adicionar ingrediente"; + +// Meal types (2.0) +"breakfast" = "Café da manhã"; +"snack" = "Lanche"; diff --git a/MealMood/Services/CalendarService.swift b/MealMood/Services/CalendarService.swift index 139f979..2b11b9b 100644 --- a/MealMood/Services/CalendarService.swift +++ b/MealMood/Services/CalendarService.swift @@ -37,12 +37,12 @@ final class CalendarService { let event = EKEvent(eventStore: eventStore) let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) " - let mealNameKey = slot.mealType == "lunch" ? "lunch" : "dinner" + let mealNameKey = slot.mealTypeEnum.localizedKey let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved()) event.title = "\(prefix)\(mealName): \(dishName)" let slotDate = weekStartDate.addingDays(slot.dayOfWeek) - let slotTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime + let slotTime = settings.time(for: slot.mealTypeEnum) event.startDate = combineDateAndTime(date: slotDate, time: slotTime) event.endDate = event.startDate.addingMinutes(settings.eventDuration) event.notes = dishDescription @@ -82,12 +82,12 @@ final class CalendarService { } let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) " - let mealNameKey = slot.mealType == "lunch" ? "lunch" : "dinner" + let mealNameKey = slot.mealTypeEnum.localizedKey let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved()) event.title = "\(prefix)\(mealName): \(dishName)" let slotDate = weekStartDate.addingDays(slot.dayOfWeek) - let slotTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime + let slotTime = settings.time(for: slot.mealTypeEnum) event.startDate = combineDateAndTime(date: slotDate, time: slotTime) event.endDate = event.startDate.addingMinutes(settings.eventDuration) event.notes = dishDescription @@ -124,7 +124,7 @@ final class CalendarService { event.startDate >= Date() else { continue } let slotDate = weekStartDate.addingDays(slot.dayOfWeek) - let newTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime + let newTime = settings.time(for: slot.mealTypeEnum) event.startDate = combineDateAndTime(date: slotDate, time: newTime) event.endDate = event.startDate.addingMinutes(settings.eventDuration) diff --git a/MealMood/Services/DefaultDataService.swift b/MealMood/Services/DefaultDataService.swift index 91bee32..0ff4367 100644 --- a/MealMood/Services/DefaultDataService.swift +++ b/MealMood/Services/DefaultDataService.swift @@ -48,13 +48,7 @@ struct DefaultDataService { context.insert(plan) let maxDay = settings.includeWeekends ? 6 : 4 - let mealTypes: [String] = { - switch settings.mealWindowsEnum { - case .dinnerOnly: return ["dinner"] - case .lunchOnly: return ["lunch"] - case .both: return ["lunch", "dinner"] - } - }() + let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue) for day in 0...maxDay { for mealType in mealTypes { diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index 138cac6..e371780 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -59,6 +59,7 @@ final class ICloudSyncService { settings: settings.map { SettingsPayload( mealWindows: $0.mealWindows, + enabledMealTypesRaw: $0.enabledMealTypesRaw, includeWeekends: $0.includeWeekends, language: $0.language, calendarId: $0.calendarId, @@ -141,6 +142,7 @@ final class ICloudSyncService { if let settingsPayload = snapshot.settings { let settings = AppSettings() settings.mealWindows = settingsPayload.mealWindows + settings.enabledMealTypesRaw = settingsPayload.enabledMealTypesRaw settings.includeWeekends = settingsPayload.includeWeekends settings.language = settingsPayload.language settings.calendarId = settingsPayload.calendarId @@ -227,6 +229,8 @@ private struct SyncSnapshot: Codable { private struct SettingsPayload: Codable { let mealWindows: String + // Optional for backward-compatibility with pre-2.0 snapshots. + let enabledMealTypesRaw: String? let includeWeekends: Bool let language: String let calendarId: String? diff --git a/MealMood/Services/WidgetDataStore.swift b/MealMood/Services/WidgetDataStore.swift index 2475a2e..8ca96e7 100644 --- a/MealMood/Services/WidgetDataStore.swift +++ b/MealMood/Services/WidgetDataStore.swift @@ -1,11 +1,16 @@ import Foundation import WidgetKit +/// Snapshot of today's meals for the widget. Generic over meal types (2.0): +/// one entry per active meal type, in chronological order. struct TodayMealData: Codable { - let lunch: String? - let dinner: String? - let lunchLabel: String - let dinnerLabel: String + struct Meal: Codable { + let type: String // MealType raw value (drives icon/color in widget) + let label: String // localized meal name + let name: String? // dish name, nil when the slot is empty + } + + let meals: [Meal] let weekdayName: String let updatedAt: Date } @@ -20,11 +25,17 @@ enum WidgetDataStore { // weekday: 1=Sun,2=Mon..7=Sat → appDayOfWeek: 0=Mon..6=Sun let appDayOfWeek = (weekday + 5) % 7 - let lunchId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.lunch.rawValue }?.dishId - let dinnerId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.dinner.rawValue }?.dishId - - let lunch = lunchId.flatMap { id in dishes.first { $0.id == id }?.name } - let dinner = dinnerId.flatMap { id in dishes.first { $0.id == id }?.name } + let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in + let dishId = plan?.slots.first { + $0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue + }?.dishId + let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name } + return TodayMealData.Meal( + type: mealType.rawValue, + label: String(localized: String.LocalizationValue(mealType.localizedKey)), + name: name + ) + } let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier) let formatter = DateFormatter() @@ -33,10 +44,7 @@ enum WidgetDataStore { let weekdayName = formatter.string(from: today).capitalized(with: locale) let data = TodayMealData( - lunch: lunch, - dinner: dinner, - lunchLabel: String(localized: "lunch"), - dinnerLabel: String(localized: "dinner"), + meals: meals, weekdayName: weekdayName, updatedAt: today ) diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 513ec4b..6d0f7ba 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -377,7 +377,7 @@ final class HomeViewModel: ObservableObject { } private func mealTypeOrder(_ raw: String) -> Int { - raw == MealType.lunch.rawValue ? 0 : 1 + MealType(rawValue: raw)?.order ?? 0 } private func applyCalendarSyncPolicy(plan: WeekPlan, settings: AppSettings, shouldNotify: Bool = true) { @@ -495,13 +495,7 @@ final class HomeViewModel: ObservableObject { private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) { let maxDay = settings.includeWeekends ? 6 : 4 - let mealTypes: [String] = { - switch settings.mealWindowsEnum { - case .dinnerOnly: return ["dinner"] - case .lunchOnly: return ["lunch"] - case .both: return ["lunch", "dinner"] - } - }() + let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue) // Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable. var groupedByKey: [SlotKey: [MealSlot]] = [:] diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index 40b4354..a876f9b 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -8,7 +8,7 @@ final class OnboardingViewModel: ObservableObject { static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch" @Published var currentStep: Int = 0 - @Published var selectedMealWindows: MealWindows = .dinnerOnly + @Published var selectedMealTypes: Set = [.dinner] @Published var includeWeekends: Bool = true @Published var syncICloud: Bool = true @Published var syncCalendar: Bool = false @@ -130,7 +130,7 @@ final class OnboardingViewModel: ObservableObject { return s }() - settings.mealWindowsEnum = selectedMealWindows + settings.activeMealTypes = MealType.allCases.filter { selectedMealTypes.contains($0) } settings.includeWeekends = includeWeekends settings.iCloudSyncEnabled = syncICloud settings.syncEnabled = syncCalendar diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index fe9b48f..3c95291 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -211,6 +211,11 @@ struct HomeView: View { let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } + .onChange(of: settings?.enabledMealTypesRaw) { _, _ in + guard let settings = settings, + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } + viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) + } .onChange(of: settings?.mealWindows) { _, _ in guard let settings = settings, let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 8003faf..6a1bf36 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -22,11 +22,7 @@ struct WeekCalendarView: View { } private var mealTypes: [MealType] { - switch settings.mealWindowsEnum { - case .dinnerOnly: return [.dinner] - case .lunchOnly: return [.lunch] - case .both: return [.lunch, .dinner] - } + settings.activeMealTypes } var body: some View { diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index 21178bd..2d18faf 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -45,11 +45,7 @@ struct WeekPlanShareView: View { } private var mealTypes: [MealType] { - switch settings.mealWindowsEnum { - case .dinnerOnly: return [.dinner] - case .lunchOnly: return [.lunch] - case .both: return [.lunch, .dinner] - } + settings.activeMealTypes } private var exportStyle: WeekExportStyle { @@ -389,11 +385,11 @@ struct WeekPlanShareView: View { HStack(spacing: 14) { Image(systemName: mealType.icon) .font(.system(size: 42, weight: .medium)) - .foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A")) + .foregroundColor(mealTypeAccent(mealType)) .frame(width: 56) Text(mealTypeLabel(mealType).uppercased(with: locale)) .font(.system(size: 42, weight: .bold, design: .serif)) - .foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A")) + .foregroundColor(mealTypeAccent(mealType)) } Text(dishName(day: day, mealType: mealType)) .font(.system(size: 62, weight: .medium, design: .serif)) @@ -497,7 +493,17 @@ struct WeekPlanShareView: View { } private func mealTypeLabel(_ mealType: MealType) -> String { - mealType == .lunch ? String(localized: "lunch") : String(localized: "dinner") + String(localized: String.LocalizationValue(mealType.localizedKey)) + } + + /// Accent color per meal row in the vertical/school styles. + private func mealTypeAccent(_ mealType: MealType) -> Color { + switch mealType { + case .breakfast: return Color(hex: "#B07C2B") + case .lunch: return Color(hex: "#C85E2A") + case .snack: return Color(hex: "#7B5EA7") + case .dinner: return Color(hex: "#27604A") + } } private func dishName(day: Int, mealType: MealType) -> String { @@ -526,9 +532,17 @@ struct WeekPlanShareView: View { } private func gridMealCell(_ title: String, icon: String, mealType: MealType) -> some View { - let background = mealType == .lunch ? - LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing) : - LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing) + let background: LinearGradient + switch mealType { + case .breakfast: + background = LinearGradient(colors: [Color(hex: "#FFEFC9"), Color(hex: "#FFE5A9")], startPoint: .topLeading, endPoint: .bottomTrailing) + case .lunch: + background = LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing) + case .snack: + background = LinearGradient(colors: [Color(hex: "#EBE0F5"), Color(hex: "#DFD0EF")], startPoint: .topLeading, endPoint: .bottomTrailing) + case .dinner: + background = LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing) + } return HStack(spacing: 8) { Image(systemName: icon) Text(title) diff --git a/MealMood/Views/Onboarding/MealWindowsStepView.swift b/MealMood/Views/Onboarding/MealWindowsStepView.swift index bba6451..78e6b20 100644 --- a/MealMood/Views/Onboarding/MealWindowsStepView.swift +++ b/MealMood/Views/Onboarding/MealWindowsStepView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MealWindowsStepView: View { - @Binding var selection: MealWindows + @Binding var selection: Set var onNext: () -> Void var body: some View { @@ -15,14 +15,21 @@ struct MealWindowsStepView: View { .padding(.top, 12) VStack(spacing: 12) { - ForEach(MealWindows.allCases, id: \.self) { option in + ForEach(MealType.allCases, id: \.self) { mealType in SelectionCard( - icon: option.icon, - title: optionTitle(option), - isSelected: selection == option + icon: mealType.icon, + title: optionTitle(mealType), + isSelected: selection.contains(mealType) ) { withAnimation(.spring(response: 0.3)) { - selection = option + if selection.contains(mealType) { + // Keep at least one meal selected. + if selection.count > 1 { + selection.remove(mealType) + } + } else { + selection.insert(mealType) + } } HapticManager.shared.selection() } @@ -38,12 +45,15 @@ struct MealWindowsStepView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - private func optionTitle(_ option: MealWindows) -> String { - switch option { - case .dinnerOnly: return "🌙 \(String(localized: "meal_windows_dinner_only"))" - case .lunchOnly: return "☀️ \(String(localized: "meal_windows_lunch_only"))" - case .both: return "🌞🌙 \(String(localized: "meal_windows_both"))" + private func optionTitle(_ mealType: MealType) -> String { + let emoji: String + switch mealType { + case .breakfast: emoji = "☕️" + case .lunch: emoji = "🌞" + case .snack: emoji = "🍎" + case .dinner: emoji = "🌙" } + return "\(emoji) \(String(localized: String.LocalizationValue(mealType.localizedKey)))" } } diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index c7cd76a..f14b974 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -31,7 +31,7 @@ struct OnboardingView: View { .tag(0) MealWindowsStepView( - selection: $viewModel.selectedMealWindows, + selection: $viewModel.selectedMealTypes, onNext: { viewModel.nextStep() } ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/MealMood/Views/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift index 0775362..1d528fb 100644 --- a/MealMood/Views/Settings/SettingsView.swift +++ b/MealMood/Views/Settings/SettingsView.swift @@ -61,13 +61,26 @@ struct SettingsView: View { List { // Planning section Section { - Picker("settings_meal_windows", selection: Binding( - get: { settings.mealWindowsEnum }, - set: { settings.mealWindowsEnum = $0 } - )) { - Text("meal_windows_dinner_only").tag(MealWindows.dinnerOnly) - Text("meal_windows_lunch_only").tag(MealWindows.lunchOnly) - Text("meal_windows_both").tag(MealWindows.both) + ForEach(MealType.allCases, id: \.self) { mealType in + Toggle(isOn: Binding( + get: { settings.activeMealTypes.contains(mealType) }, + set: { enabled in + var types = settings.activeMealTypes + if enabled { + types.append(mealType) + } else if types.count > 1 { + // Keep at least one meal type active. + types.removeAll { $0 == mealType } + } + settings.activeMealTypes = types + } + )) { + Label( + String(localized: String.LocalizationValue(mealType.localizedKey)), + systemImage: mealType.icon + ) + } + .tint(.mealMoodCoral) } Toggle("settings_include_weekends", isOn: Binding( diff --git a/MealMoodWidget/MealMoodWidget.swift b/MealMoodWidget/MealMoodWidget.swift index 0279d21..5a10bbd 100644 --- a/MealMoodWidget/MealMoodWidget.swift +++ b/MealMoodWidget/MealMoodWidget.swift @@ -5,10 +5,13 @@ import Foundation // MARK: - Shared data model (mirrors WidgetDataStore in main app) private struct TodayMealData: Codable { - let lunch: String? - let dinner: String? - let lunchLabel: String - let dinnerLabel: String + struct Meal: Codable { + let type: String + let label: String + let name: String? + } + + let meals: [Meal] let weekdayName: String let updatedAt: Date } @@ -26,39 +29,70 @@ private enum WidgetStore { // MARK: - Timeline +struct MealItem: Hashable { + let type: String + let label: String + let name: String? + + var icon: String { + switch type { + case "breakfast": return "cup.and.saucer.fill" + case "lunch": return "sun.max.fill" + case "snack": return "carrot.fill" + default: return "moon.stars.fill" + } + } + + var color: Color { + switch type { + case "breakfast": return .mmBreakfastAmber + case "lunch": return .mmLunchOrange + case "snack": return .mmSnackPurple + default: return .mmDinnerBlue + } + } +} + struct TodayMealEntry: TimelineEntry { let date: Date - let lunch: String? - let dinner: String? - let lunchLabel: String - let dinnerLabel: String + let meals: [MealItem] let weekdayName: String + + /// Compact families show the "main" meals when the list is long. + var compactMeals: [MealItem] { + guard meals.count > 2 else { return meals } + let main = meals.filter { $0.type == "lunch" || $0.type == "dinner" } + return main.isEmpty ? Array(meals.prefix(2)) : Array(main.prefix(2)) + } } struct Provider: TimelineProvider { func placeholder(in context: Context) -> TodayMealEntry { - TodayMealEntry(date: Date(), lunch: "Pasta al pesto", dinner: "Salmón al horno", - lunchLabel: "Comida", dinnerLabel: "Cena", weekdayName: "Lunes") + TodayMealEntry( + date: Date(), + meals: [ + MealItem(type: "lunch", label: "Comida", name: "Pasta al pesto"), + MealItem(type: "dinner", label: "Cena", name: "Salmón al horno") + ], + weekdayName: "Lunes" + ) } func getSnapshot(in context: Context, completion: @escaping (TodayMealEntry) -> Void) { - let data = WidgetStore.read() - completion(entry(from: data)) + completion(entry(from: WidgetStore.read())) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let data = WidgetStore.read() let nextMidnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!) - completion(Timeline(entries: [entry(from: data)], policy: .after(nextMidnight))) + completion(Timeline(entries: [entry(from: WidgetStore.read())], policy: .after(nextMidnight))) } private func entry(from data: TodayMealData?) -> TodayMealEntry { TodayMealEntry( date: Date(), - lunch: data?.lunch, - dinner: data?.dinner, - lunchLabel: data?.lunchLabel ?? "Lunch", - dinnerLabel: data?.dinnerLabel ?? "Dinner", + meals: (data?.meals ?? []).map { + MealItem(type: $0.type, label: $0.label, name: $0.name) + }, weekdayName: data?.weekdayName ?? "" ) } @@ -72,8 +106,13 @@ private extension Color { static let mmBgMint = Color(red: 0.93, green: 0.98, blue: 0.95) static let mmText = Color(red: 0.13, green: 0.13, blue: 0.13) static let mmSubtext = Color(red: 0.55, green: 0.55, blue: 0.55) - static let mmLunchOrange = Color(red: 0.90, green: 0.55, blue: 0.18) - static let mmDinnerBlue = Color(red: 0.36, green: 0.48, blue: 0.78) +} + +extension Color { + static let mmBreakfastAmber = Color(red: 0.76, green: 0.55, blue: 0.18) + static let mmLunchOrange = Color(red: 0.90, green: 0.55, blue: 0.18) + static let mmSnackPurple = Color(red: 0.51, green: 0.39, blue: 0.69) + static let mmDinnerBlue = Color(red: 0.36, green: 0.48, blue: 0.78) } // MARK: - Small widget @@ -102,12 +141,16 @@ private struct SmallWidgetView: View { Spacer() - mealRow(icon: "sun.max.fill", label: entry.lunchLabel, - name: entry.lunch, color: .mmLunchOrange) - .padding(.bottom, 8) - - mealRow(icon: "moon.stars.fill", label: entry.dinnerLabel, - name: entry.dinner, color: .mmDinnerBlue) + let rows = entry.compactMeals + ForEach(Array(rows.enumerated()), id: \.element) { index, meal in + mealRow(meal) + .padding(.bottom, index < rows.count - 1 ? 8 : 0) + } + if rows.isEmpty { + Text("—") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.mmSubtext) + } } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) @@ -117,17 +160,17 @@ private struct SmallWidgetView: View { ) } - private func mealRow(icon: String, label: String, name: String?, color: Color) -> some View { + private func mealRow(_ meal: MealItem) -> some View { HStack(spacing: 8) { - Image(systemName: icon) + Image(systemName: meal.icon) .font(.system(size: 13)) - .foregroundColor(color) + .foregroundColor(meal.color) .frame(width: 18) VStack(alignment: .leading, spacing: 1) { - Text(label.uppercased()) + Text(meal.label.uppercased()) .font(.system(size: 8, weight: .semibold)) .foregroundColor(.mmSubtext) - Text(name ?? "—") + Text(meal.name ?? "—") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.mmText) .lineLimit(2) @@ -167,12 +210,19 @@ private struct MediumWidgetView: View { .fill(Color.mmCoral.opacity(0.2)) .frame(width: 1) - VStack(alignment: .leading, spacing: 14) { - mediumRow(icon: "sun.max.fill", label: entry.lunchLabel, - name: entry.lunch, color: .mmLunchOrange) - Divider() - mediumRow(icon: "moon.stars.fill", label: entry.dinnerLabel, - name: entry.dinner, color: .mmDinnerBlue) + let rows = entry.meals.isEmpty ? entry.compactMeals : entry.meals + VStack(alignment: .leading, spacing: rows.count > 2 ? 8 : 14) { + ForEach(Array(rows.enumerated()), id: \.element) { index, meal in + mediumRow(meal, compact: rows.count > 2) + if index < rows.count - 1 { + Divider() + } + } + if rows.isEmpty { + Text("—") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.mmSubtext) + } } .padding(14) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) @@ -180,20 +230,31 @@ private struct MediumWidgetView: View { } } - private func mediumRow(icon: String, label: String, name: String?, color: Color) -> some View { + private func mediumRow(_ meal: MealItem, compact: Bool) -> some View { HStack(spacing: 10) { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(color) + Image(systemName: meal.icon) + .font(.system(size: compact ? 13 : 16)) + .foregroundColor(meal.color) .frame(width: 22) - VStack(alignment: .leading, spacing: 2) { - Text(label.uppercased()) + if compact { + Text(meal.label.uppercased()) .font(.system(size: 9, weight: .semibold)) .foregroundColor(.mmSubtext) - Text(name ?? "—") - .font(.system(size: 15, weight: .semibold)) + .frame(width: 70, alignment: .leading) + Text(meal.name ?? "—") + .font(.system(size: 13, weight: .semibold)) .foregroundColor(.mmText) - .lineLimit(2) + .lineLimit(1) + } else { + VStack(alignment: .leading, spacing: 2) { + Text(meal.label.uppercased()) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.mmSubtext) + Text(meal.name ?? "—") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.mmText) + .lineLimit(2) + } } } } @@ -215,17 +276,13 @@ private struct AccessoryRectangularView: View { } .widgetAccentable() - if let lunch = entry.lunch { - Text("\(Image(systemName: "sun.max.fill")) \(lunch)") + let rows = entry.compactMeals.filter { $0.name != nil } + ForEach(rows.prefix(2), id: \.self) { meal in + Text("\(Image(systemName: meal.icon)) \(meal.name ?? "")") .font(.system(size: 12)) .lineLimit(1) } - if let dinner = entry.dinner { - Text("\(Image(systemName: "moon.stars.fill")) \(dinner)") - .font(.system(size: 12)) - .lineLimit(1) - } - if entry.lunch == nil && entry.dinner == nil { + if rows.isEmpty { Text("—") .font(.system(size: 12)) } @@ -238,8 +295,8 @@ private struct AccessoryInlineView: View { let entry: TodayMealEntry var body: some View { - // Inline is a single line: show the next relevant meal. - let name = entry.dinner ?? entry.lunch + // Inline is a single line: prefer the last main meal with a dish. + let name = entry.compactMeals.reversed().compactMap(\.name).first Label(name ?? "MealMood", systemImage: "fork.knife") } } @@ -283,7 +340,7 @@ struct MealMoodWidget: Widget { MealMoodWidgetView(entry: entry) } .configurationDisplayName("MealMood") - .description("Today's lunch and dinner at a glance.") + .description("Today's meals at a glance.") .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } }