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 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 {