2.0: breakfast and snack meal types across the app

- MealType gains breakfast/snack (chronological order drives slot/row order).
- AppSettings.activeMealTypes: single source of truth for enabled meals, backed
  by new optional enabledMealTypesRaw with legacy mealWindows fallback —
  additive migration, keeps pre-2.0 installs and iCloud snapshots working.
  Legacy field kept coherent by the setter. time(for:) resolves per-meal event
  times (breakfast 8:00 / snack 17:00 defaults on old stores).
- Replaced the 5 duplicated mealWindows→[MealType] derivations (slot sync,
  default plan creation, week calendar, export view) with activeMealTypes.
- CalendarService: per-type event names and times.
- Export styles: per-type accent colors and gradients.
- Widget: generic meals list (N rows) instead of hardcoded lunch/dinner;
  compact families fall back to main meals when >2 are active.
- Settings: 4 meal toggles (min 1) replace the 3-option picker.
- Onboarding: meal step is now multi-select over the 4 types.
- iCloud sync: enabledMealTypesRaw synced (optional, pre-2.0 compatible).
- Localized breakfast/snack in all 6 languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
alexandrev-tibco
2026-07-12 18:00:50 +02:00
parent 98230c732d
commit e15ff93465
20 changed files with 322 additions and 127 deletions
+76
View File
@@ -7,11 +7,17 @@ final class AppSettings {
var includeWeekends: Bool = true var includeWeekends: Bool = true
var language: String // "spanish", "english" 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 calendarId: String?
var syncEnabled: Bool = false var syncEnabled: Bool = false
var syncMode: String? // "weekComplete", "manual" var syncMode: String? // "weekComplete", "manual"
var lunchTime: Date var lunchTime: Date
var dinnerTime: Date var dinnerTime: Date
var breakfastTime: Date? // optional: pre-2.0 stores lack it
var snackTime: Date?
var eventDuration: Int var eventDuration: Int
var eventPrefix: String var eventPrefix: String
var reminderMinutesBefore: Int? var reminderMinutesBefore: Int?
@@ -71,6 +77,57 @@ final class AppSettings {
get { WeekExportStyle(rawValue: weekExportStyle ?? "") ?? .defaultStyle } get { WeekExportStyle(rawValue: weekExportStyle ?? "") ?? .defaultStyle }
set { weekExportStyle = newValue.rawValue } 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 { enum MealWindows: String, CaseIterable {
@@ -142,15 +199,34 @@ enum AppLanguage: String, CaseIterable {
} }
enum MealType: String, Codable, CaseIterable { enum MealType: String, Codable, CaseIterable {
// Declaration order is chronological allCases drives row/slot ordering.
case breakfast
case lunch case lunch
case snack
case dinner case dinner
var icon: String { var icon: String {
switch self { switch self {
case .breakfast: return "cup.and.saucer.fill"
case .lunch: return "sun.max.fill" case .lunch: return "sun.max.fill"
case .snack: return "carrot.fill"
case .dinner: return "moon.stars.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 { enum CalendarSyncMode: String, CaseIterable {
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Zutaten"; "dish_ingredients_label" = "Zutaten";
"dish_ingredient_placeholder" = "z. B. 200 g Spaghetti"; "dish_ingredient_placeholder" = "z. B. 200 g Spaghetti";
"dish_ingredients_add" = "Zutat hinzufügen"; "dish_ingredients_add" = "Zutat hinzufügen";
// Meal types (2.0)
"breakfast" = "Frühstück";
"snack" = "Snack";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredients"; "dish_ingredients_label" = "Ingredients";
"dish_ingredient_placeholder" = "e.g. 200 g spaghetti"; "dish_ingredient_placeholder" = "e.g. 200 g spaghetti";
"dish_ingredients_add" = "Add ingredient"; "dish_ingredients_add" = "Add ingredient";
// Meal types (2.0)
"breakfast" = "Breakfast";
"snack" = "Snack";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredientes"; "dish_ingredients_label" = "Ingredientes";
"dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis"; "dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis";
"dish_ingredients_add" = "Añadir ingrediente"; "dish_ingredients_add" = "Añadir ingrediente";
// Meal types (2.0)
"breakfast" = "Desayuno";
"snack" = "Merienda";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingrédients"; "dish_ingredients_label" = "Ingrédients";
"dish_ingredient_placeholder" = "ex. 200 g de spaghettis"; "dish_ingredient_placeholder" = "ex. 200 g de spaghettis";
"dish_ingredients_add" = "Ajouter un ingrédient"; "dish_ingredients_add" = "Ajouter un ingrédient";
// Meal types (2.0)
"breakfast" = "Petit-déjeuner";
"snack" = "Goûter";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredienti"; "dish_ingredients_label" = "Ingredienti";
"dish_ingredient_placeholder" = "es. 200 g di spaghetti"; "dish_ingredient_placeholder" = "es. 200 g di spaghetti";
"dish_ingredients_add" = "Aggiungi ingrediente"; "dish_ingredients_add" = "Aggiungi ingrediente";
// Meal types (2.0)
"breakfast" = "Colazione";
"snack" = "Merenda";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredientes"; "dish_ingredients_label" = "Ingredientes";
"dish_ingredient_placeholder" = "ex.: 200 g de espaguete"; "dish_ingredient_placeholder" = "ex.: 200 g de espaguete";
"dish_ingredients_add" = "Adicionar ingrediente"; "dish_ingredients_add" = "Adicionar ingrediente";
// Meal types (2.0)
"breakfast" = "Café da manhã";
"snack" = "Lanche";
+5 -5
View File
@@ -37,12 +37,12 @@ final class CalendarService {
let event = EKEvent(eventStore: eventStore) let event = EKEvent(eventStore: eventStore)
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) " 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()) let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
event.title = "\(prefix)\(mealName): \(dishName)" event.title = "\(prefix)\(mealName): \(dishName)"
let slotDate = weekStartDate.addingDays(slot.dayOfWeek) 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.startDate = combineDateAndTime(date: slotDate, time: slotTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration) event.endDate = event.startDate.addingMinutes(settings.eventDuration)
event.notes = dishDescription event.notes = dishDescription
@@ -82,12 +82,12 @@ final class CalendarService {
} }
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) " 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()) let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
event.title = "\(prefix)\(mealName): \(dishName)" event.title = "\(prefix)\(mealName): \(dishName)"
let slotDate = weekStartDate.addingDays(slot.dayOfWeek) 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.startDate = combineDateAndTime(date: slotDate, time: slotTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration) event.endDate = event.startDate.addingMinutes(settings.eventDuration)
event.notes = dishDescription event.notes = dishDescription
@@ -124,7 +124,7 @@ final class CalendarService {
event.startDate >= Date() else { continue } event.startDate >= Date() else { continue }
let slotDate = weekStartDate.addingDays(slot.dayOfWeek) 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.startDate = combineDateAndTime(date: slotDate, time: newTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration) event.endDate = event.startDate.addingMinutes(settings.eventDuration)
+1 -7
View File
@@ -48,13 +48,7 @@ struct DefaultDataService {
context.insert(plan) context.insert(plan)
let maxDay = settings.includeWeekends ? 6 : 4 let maxDay = settings.includeWeekends ? 6 : 4
let mealTypes: [String] = { let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue)
switch settings.mealWindowsEnum {
case .dinnerOnly: return ["dinner"]
case .lunchOnly: return ["lunch"]
case .both: return ["lunch", "dinner"]
}
}()
for day in 0...maxDay { for day in 0...maxDay {
for mealType in mealTypes { for mealType in mealTypes {
@@ -59,6 +59,7 @@ final class ICloudSyncService {
settings: settings.map { settings: settings.map {
SettingsPayload( SettingsPayload(
mealWindows: $0.mealWindows, mealWindows: $0.mealWindows,
enabledMealTypesRaw: $0.enabledMealTypesRaw,
includeWeekends: $0.includeWeekends, includeWeekends: $0.includeWeekends,
language: $0.language, language: $0.language,
calendarId: $0.calendarId, calendarId: $0.calendarId,
@@ -141,6 +142,7 @@ final class ICloudSyncService {
if let settingsPayload = snapshot.settings { if let settingsPayload = snapshot.settings {
let settings = AppSettings() let settings = AppSettings()
settings.mealWindows = settingsPayload.mealWindows settings.mealWindows = settingsPayload.mealWindows
settings.enabledMealTypesRaw = settingsPayload.enabledMealTypesRaw
settings.includeWeekends = settingsPayload.includeWeekends settings.includeWeekends = settingsPayload.includeWeekends
settings.language = settingsPayload.language settings.language = settingsPayload.language
settings.calendarId = settingsPayload.calendarId settings.calendarId = settingsPayload.calendarId
@@ -227,6 +229,8 @@ private struct SyncSnapshot: Codable {
private struct SettingsPayload: Codable { private struct SettingsPayload: Codable {
let mealWindows: String let mealWindows: String
// Optional for backward-compatibility with pre-2.0 snapshots.
let enabledMealTypesRaw: String?
let includeWeekends: Bool let includeWeekends: Bool
let language: String let language: String
let calendarId: String? let calendarId: String?
+21 -13
View File
@@ -1,11 +1,16 @@
import Foundation import Foundation
import WidgetKit 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 { struct TodayMealData: Codable {
let lunch: String? struct Meal: Codable {
let dinner: String? let type: String // MealType raw value (drives icon/color in widget)
let lunchLabel: String let label: String // localized meal name
let dinnerLabel: String let name: String? // dish name, nil when the slot is empty
}
let meals: [Meal]
let weekdayName: String let weekdayName: String
let updatedAt: Date let updatedAt: Date
} }
@@ -20,11 +25,17 @@ enum WidgetDataStore {
// weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun // weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun
let appDayOfWeek = (weekday + 5) % 7 let appDayOfWeek = (weekday + 5) % 7
let lunchId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.lunch.rawValue }?.dishId let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in
let dinnerId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.dinner.rawValue }?.dishId let dishId = plan?.slots.first {
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
let lunch = lunchId.flatMap { id in dishes.first { $0.id == id }?.name } }?.dishId
let dinner = dinnerId.flatMap { id in dishes.first { $0.id == id }?.name } 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 locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -33,10 +44,7 @@ enum WidgetDataStore {
let weekdayName = formatter.string(from: today).capitalized(with: locale) let weekdayName = formatter.string(from: today).capitalized(with: locale)
let data = TodayMealData( let data = TodayMealData(
lunch: lunch, meals: meals,
dinner: dinner,
lunchLabel: String(localized: "lunch"),
dinnerLabel: String(localized: "dinner"),
weekdayName: weekdayName, weekdayName: weekdayName,
updatedAt: today updatedAt: today
) )
+2 -8
View File
@@ -377,7 +377,7 @@ final class HomeViewModel: ObservableObject {
} }
private func mealTypeOrder(_ raw: String) -> Int { 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) { 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) { private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) {
let maxDay = settings.includeWeekends ? 6 : 4 let maxDay = settings.includeWeekends ? 6 : 4
let mealTypes: [String] = { let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue)
switch settings.mealWindowsEnum {
case .dinnerOnly: return ["dinner"]
case .lunchOnly: return ["lunch"]
case .both: return ["lunch", "dinner"]
}
}()
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable. // Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
var groupedByKey: [SlotKey: [MealSlot]] = [:] var groupedByKey: [SlotKey: [MealSlot]] = [:]
@@ -8,7 +8,7 @@ final class OnboardingViewModel: ObservableObject {
static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch" static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch"
@Published var currentStep: Int = 0 @Published var currentStep: Int = 0
@Published var selectedMealWindows: MealWindows = .dinnerOnly @Published var selectedMealTypes: Set<MealType> = [.dinner]
@Published var includeWeekends: Bool = true @Published var includeWeekends: Bool = true
@Published var syncICloud: Bool = true @Published var syncICloud: Bool = true
@Published var syncCalendar: Bool = false @Published var syncCalendar: Bool = false
@@ -130,7 +130,7 @@ final class OnboardingViewModel: ObservableObject {
return s return s
}() }()
settings.mealWindowsEnum = selectedMealWindows settings.activeMealTypes = MealType.allCases.filter { selectedMealTypes.contains($0) }
settings.includeWeekends = includeWeekends settings.includeWeekends = includeWeekends
settings.iCloudSyncEnabled = syncICloud settings.iCloudSyncEnabled = syncICloud
settings.syncEnabled = syncCalendar settings.syncEnabled = syncCalendar
+5
View File
@@ -211,6 +211,11 @@ struct HomeView: View {
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) 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 .onChange(of: settings?.mealWindows) { _, _ in
guard let settings = settings, guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
+1 -5
View File
@@ -22,11 +22,7 @@ struct WeekCalendarView: View {
} }
private var mealTypes: [MealType] { private var mealTypes: [MealType] {
switch settings.mealWindowsEnum { settings.activeMealTypes
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
} }
var body: some View { var body: some View {
+25 -11
View File
@@ -45,11 +45,7 @@ struct WeekPlanShareView: View {
} }
private var mealTypes: [MealType] { private var mealTypes: [MealType] {
switch settings.mealWindowsEnum { settings.activeMealTypes
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
} }
private var exportStyle: WeekExportStyle { private var exportStyle: WeekExportStyle {
@@ -389,11 +385,11 @@ struct WeekPlanShareView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: mealType.icon) Image(systemName: mealType.icon)
.font(.system(size: 42, weight: .medium)) .font(.system(size: 42, weight: .medium))
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A")) .foregroundColor(mealTypeAccent(mealType))
.frame(width: 56) .frame(width: 56)
Text(mealTypeLabel(mealType).uppercased(with: locale)) Text(mealTypeLabel(mealType).uppercased(with: locale))
.font(.system(size: 42, weight: .bold, design: .serif)) .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)) Text(dishName(day: day, mealType: mealType))
.font(.system(size: 62, weight: .medium, design: .serif)) .font(.system(size: 62, weight: .medium, design: .serif))
@@ -497,7 +493,17 @@ struct WeekPlanShareView: View {
} }
private func mealTypeLabel(_ mealType: MealType) -> String { 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 { 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 { private func gridMealCell(_ title: String, icon: String, mealType: MealType) -> some View {
let background = mealType == .lunch ? let background: LinearGradient
LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing) : switch mealType {
LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing) 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) { return HStack(spacing: 8) {
Image(systemName: icon) Image(systemName: icon)
Text(title) Text(title)
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct MealWindowsStepView: View { struct MealWindowsStepView: View {
@Binding var selection: MealWindows @Binding var selection: Set<MealType>
var onNext: () -> Void var onNext: () -> Void
var body: some View { var body: some View {
@@ -15,14 +15,21 @@ struct MealWindowsStepView: View {
.padding(.top, 12) .padding(.top, 12)
VStack(spacing: 12) { VStack(spacing: 12) {
ForEach(MealWindows.allCases, id: \.self) { option in ForEach(MealType.allCases, id: \.self) { mealType in
SelectionCard( SelectionCard(
icon: option.icon, icon: mealType.icon,
title: optionTitle(option), title: optionTitle(mealType),
isSelected: selection == option isSelected: selection.contains(mealType)
) { ) {
withAnimation(.spring(response: 0.3)) { 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() HapticManager.shared.selection()
} }
@@ -38,12 +45,15 @@ struct MealWindowsStepView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
private func optionTitle(_ option: MealWindows) -> String { private func optionTitle(_ mealType: MealType) -> String {
switch option { let emoji: String
case .dinnerOnly: return "🌙 \(String(localized: "meal_windows_dinner_only"))" switch mealType {
case .lunchOnly: return "☀️ \(String(localized: "meal_windows_lunch_only"))" case .breakfast: emoji = "☕️"
case .both: return "🌞🌙 \(String(localized: "meal_windows_both"))" case .lunch: emoji = "🌞"
case .snack: emoji = "🍎"
case .dinner: emoji = "🌙"
} }
return "\(emoji) \(String(localized: String.LocalizationValue(mealType.localizedKey)))"
} }
} }
@@ -31,7 +31,7 @@ struct OnboardingView: View {
.tag(0) .tag(0)
MealWindowsStepView( MealWindowsStepView(
selection: $viewModel.selectedMealWindows, selection: $viewModel.selectedMealTypes,
onNext: { viewModel.nextStep() } onNext: { viewModel.nextStep() }
) )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+20 -7
View File
@@ -61,13 +61,26 @@ struct SettingsView: View {
List { List {
// Planning section // Planning section
Section { Section {
Picker("settings_meal_windows", selection: Binding( ForEach(MealType.allCases, id: \.self) { mealType in
get: { settings.mealWindowsEnum }, Toggle(isOn: Binding(
set: { settings.mealWindowsEnum = $0 } get: { settings.activeMealTypes.contains(mealType) },
)) { set: { enabled in
Text("meal_windows_dinner_only").tag(MealWindows.dinnerOnly) var types = settings.activeMealTypes
Text("meal_windows_lunch_only").tag(MealWindows.lunchOnly) if enabled {
Text("meal_windows_both").tag(MealWindows.both) 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( Toggle("settings_include_weekends", isOn: Binding(
+114 -57
View File
@@ -5,10 +5,13 @@ import Foundation
// MARK: - Shared data model (mirrors WidgetDataStore in main app) // MARK: - Shared data model (mirrors WidgetDataStore in main app)
private struct TodayMealData: Codable { private struct TodayMealData: Codable {
let lunch: String? struct Meal: Codable {
let dinner: String? let type: String
let lunchLabel: String let label: String
let dinnerLabel: String let name: String?
}
let meals: [Meal]
let weekdayName: String let weekdayName: String
let updatedAt: Date let updatedAt: Date
} }
@@ -26,39 +29,70 @@ private enum WidgetStore {
// MARK: - Timeline // 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 { struct TodayMealEntry: TimelineEntry {
let date: Date let date: Date
let lunch: String? let meals: [MealItem]
let dinner: String?
let lunchLabel: String
let dinnerLabel: String
let weekdayName: String 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 { struct Provider: TimelineProvider {
func placeholder(in context: Context) -> TodayMealEntry { func placeholder(in context: Context) -> TodayMealEntry {
TodayMealEntry(date: Date(), lunch: "Pasta al pesto", dinner: "Salmón al horno", TodayMealEntry(
lunchLabel: "Comida", dinnerLabel: "Cena", weekdayName: "Lunes") 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) { func getSnapshot(in context: Context, completion: @escaping (TodayMealEntry) -> Void) {
let data = WidgetStore.read() completion(entry(from: WidgetStore.read()))
completion(entry(from: data))
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) {
let data = WidgetStore.read()
let nextMidnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!) 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 { private func entry(from data: TodayMealData?) -> TodayMealEntry {
TodayMealEntry( TodayMealEntry(
date: Date(), date: Date(),
lunch: data?.lunch, meals: (data?.meals ?? []).map {
dinner: data?.dinner, MealItem(type: $0.type, label: $0.label, name: $0.name)
lunchLabel: data?.lunchLabel ?? "Lunch", },
dinnerLabel: data?.dinnerLabel ?? "Dinner",
weekdayName: data?.weekdayName ?? "" 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 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 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 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 // MARK: - Small widget
@@ -102,12 +141,16 @@ private struct SmallWidgetView: View {
Spacer() Spacer()
mealRow(icon: "sun.max.fill", label: entry.lunchLabel, let rows = entry.compactMeals
name: entry.lunch, color: .mmLunchOrange) ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
.padding(.bottom, 8) mealRow(meal)
.padding(.bottom, index < rows.count - 1 ? 8 : 0)
mealRow(icon: "moon.stars.fill", label: entry.dinnerLabel, }
name: entry.dinner, color: .mmDinnerBlue) if rows.isEmpty {
Text("")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.mmSubtext)
}
} }
.padding(12) .padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .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) { HStack(spacing: 8) {
Image(systemName: icon) Image(systemName: meal.icon)
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundColor(color) .foregroundColor(meal.color)
.frame(width: 18) .frame(width: 18)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(label.uppercased()) Text(meal.label.uppercased())
.font(.system(size: 8, weight: .semibold)) .font(.system(size: 8, weight: .semibold))
.foregroundColor(.mmSubtext) .foregroundColor(.mmSubtext)
Text(name ?? "") Text(meal.name ?? "")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(.mmText) .foregroundColor(.mmText)
.lineLimit(2) .lineLimit(2)
@@ -167,12 +210,19 @@ private struct MediumWidgetView: View {
.fill(Color.mmCoral.opacity(0.2)) .fill(Color.mmCoral.opacity(0.2))
.frame(width: 1) .frame(width: 1)
VStack(alignment: .leading, spacing: 14) { let rows = entry.meals.isEmpty ? entry.compactMeals : entry.meals
mediumRow(icon: "sun.max.fill", label: entry.lunchLabel, VStack(alignment: .leading, spacing: rows.count > 2 ? 8 : 14) {
name: entry.lunch, color: .mmLunchOrange) ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
Divider() mediumRow(meal, compact: rows.count > 2)
mediumRow(icon: "moon.stars.fill", label: entry.dinnerLabel, if index < rows.count - 1 {
name: entry.dinner, color: .mmDinnerBlue) Divider()
}
}
if rows.isEmpty {
Text("")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.mmSubtext)
}
} }
.padding(14) .padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .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) { HStack(spacing: 10) {
Image(systemName: icon) Image(systemName: meal.icon)
.font(.system(size: 16)) .font(.system(size: compact ? 13 : 16))
.foregroundColor(color) .foregroundColor(meal.color)
.frame(width: 22) .frame(width: 22)
VStack(alignment: .leading, spacing: 2) { if compact {
Text(label.uppercased()) Text(meal.label.uppercased())
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 9, weight: .semibold))
.foregroundColor(.mmSubtext) .foregroundColor(.mmSubtext)
Text(name ?? "") .frame(width: 70, alignment: .leading)
.font(.system(size: 15, weight: .semibold)) Text(meal.name ?? "")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.mmText) .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() .widgetAccentable()
if let lunch = entry.lunch { let rows = entry.compactMeals.filter { $0.name != nil }
Text("\(Image(systemName: "sun.max.fill")) \(lunch)") ForEach(rows.prefix(2), id: \.self) { meal in
Text("\(Image(systemName: meal.icon)) \(meal.name ?? "")")
.font(.system(size: 12)) .font(.system(size: 12))
.lineLimit(1) .lineLimit(1)
} }
if let dinner = entry.dinner { if rows.isEmpty {
Text("\(Image(systemName: "moon.stars.fill")) \(dinner)")
.font(.system(size: 12))
.lineLimit(1)
}
if entry.lunch == nil && entry.dinner == nil {
Text("") Text("")
.font(.system(size: 12)) .font(.system(size: 12))
} }
@@ -238,8 +295,8 @@ private struct AccessoryInlineView: View {
let entry: TodayMealEntry let entry: TodayMealEntry
var body: some View { var body: some View {
// Inline is a single line: show the next relevant meal. // Inline is a single line: prefer the last main meal with a dish.
let name = entry.dinner ?? entry.lunch let name = entry.compactMeals.reversed().compactMap(\.name).first
Label(name ?? "MealMood", systemImage: "fork.knife") Label(name ?? "MealMood", systemImage: "fork.knife")
} }
} }
@@ -283,7 +340,7 @@ struct MealMoodWidget: Widget {
MealMoodWidgetView(entry: entry) MealMoodWidgetView(entry: entry)
} }
.configurationDisplayName("MealMood") .configurationDisplayName("MealMood")
.description("Today's lunch and dinner at a glance.") .description("Today's meals at a glance.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
} }
} }