29cba08678
- Shopping list: dishes under "No ingredients yet" now open the dish editor on tap (pencil affordance); list reconciles on dismiss. This was a dead end on devices without Apple Intelligence (no Generate button, e.g. iPhone 14). - Planner: dish photo as a scrimmed card background in the week calendar, behind a new Settings toggle (settings_show_dish_photos, default on). New optional AppSettings.showDishPhotosInPlanner (CloudKit/migration safe). - FilledSlotView: inlines the meal-card chrome to support the photo layer. - Toggle localized in all 6 languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
266 lines
8.5 KiB
Swift
266 lines
8.5 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
|
@Model
|
|
final class AppSettings {
|
|
var mealWindows: String = "dinnerOnly" // legacy: "dinnerOnly", "lunchOnly", "both"
|
|
var includeWeekends: Bool = true
|
|
var language: String = "system"
|
|
|
|
/// 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 = AppSettings.defaultTime(hour: 14)
|
|
var dinnerTime: Date = AppSettings.defaultTime(hour: 21)
|
|
var breakfastTime: Date? // optional: pre-2.0 stores lack it
|
|
var snackTime: Date?
|
|
var eventDuration: Int = 60
|
|
var eventPrefix: String = "🍽️"
|
|
var reminderMinutesBefore: Int?
|
|
var iCloudSyncEnabled: Bool?
|
|
var weekExportStyle: String?
|
|
/// Show dish photos as planner card backgrounds (2.0). Optional for
|
|
/// CloudKit/migration compatibility; resolves to true.
|
|
var showDishPhotosInPlanner: Bool?
|
|
|
|
var isPremium: Bool = false
|
|
var onboardingCompleted: Bool = false
|
|
|
|
init() {
|
|
self.mealWindows = "dinnerOnly"
|
|
self.includeWeekends = true
|
|
self.language = "system"
|
|
self.calendarId = nil
|
|
self.syncEnabled = false
|
|
self.syncMode = CalendarSyncMode.weekComplete.rawValue
|
|
// Default lunch time 14:00
|
|
var lunchComponents = DateComponents()
|
|
lunchComponents.hour = 14
|
|
lunchComponents.minute = 0
|
|
self.lunchTime = Calendar.current.date(from: lunchComponents) ?? Date()
|
|
// Default dinner time 21:00
|
|
var dinnerComponents = DateComponents()
|
|
dinnerComponents.hour = 21
|
|
dinnerComponents.minute = 0
|
|
self.dinnerTime = Calendar.current.date(from: dinnerComponents) ?? Date()
|
|
self.eventDuration = 60
|
|
self.eventPrefix = "🍽️"
|
|
self.reminderMinutesBefore = nil
|
|
self.iCloudSyncEnabled = true
|
|
self.weekExportStyle = nil
|
|
self.isPremium = false
|
|
self.onboardingCompleted = false
|
|
}
|
|
|
|
var mealWindowsEnum: MealWindows {
|
|
get { MealWindows(rawValue: mealWindows) ?? .dinnerOnly }
|
|
set { mealWindows = newValue.rawValue }
|
|
}
|
|
|
|
var languageEnum: AppLanguage {
|
|
get { AppLanguage(rawValue: language) ?? .system }
|
|
set { language = newValue.rawValue }
|
|
}
|
|
|
|
var syncModeEnum: CalendarSyncMode {
|
|
get { CalendarSyncMode(rawValue: syncMode ?? "") ?? .weekComplete }
|
|
set { syncMode = newValue.rawValue }
|
|
}
|
|
|
|
var iCloudSyncEnabledResolved: Bool {
|
|
get { iCloudSyncEnabled ?? true }
|
|
set { iCloudSyncEnabled = newValue }
|
|
}
|
|
|
|
var showDishPhotosInPlannerResolved: Bool {
|
|
get { showDishPhotosInPlanner ?? true }
|
|
set { showDishPhotosInPlanner = newValue }
|
|
}
|
|
|
|
var weekExportStyleEnum: WeekExportStyle {
|
|
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 {
|
|
case dinnerOnly
|
|
case lunchOnly
|
|
case both
|
|
|
|
var localizedKey: String {
|
|
switch self {
|
|
case .dinnerOnly: return "meal_windows_dinner_only"
|
|
case .lunchOnly: return "meal_windows_lunch_only"
|
|
case .both: return "meal_windows_both"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .dinnerOnly: return "moon.stars.fill"
|
|
case .lunchOnly: return "sun.max.fill"
|
|
case .both: return "sun.and.horizon.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AppLanguage: String, CaseIterable {
|
|
case system
|
|
case spanish
|
|
case english
|
|
case french
|
|
case german
|
|
case italian
|
|
case portugueseBrazil
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .system: return "System"
|
|
case .spanish: return "Español"
|
|
case .english: return "English"
|
|
case .french: return "Français"
|
|
case .german: return "Deutsch"
|
|
case .italian: return "Italiano"
|
|
case .portugueseBrazil: return "Português (Brasil)"
|
|
}
|
|
}
|
|
|
|
var localeIdentifier: String {
|
|
switch self {
|
|
case .system: return resolved().localeIdentifier
|
|
case .spanish: return "es"
|
|
case .english: return "en"
|
|
case .french: return "fr"
|
|
case .german: return "de"
|
|
case .italian: return "it"
|
|
case .portugueseBrazil: return "pt-BR"
|
|
}
|
|
}
|
|
|
|
func resolved() -> AppLanguage {
|
|
if self != .system { return self }
|
|
let localeIdentifier = Locale.current.identifier.lowercased()
|
|
|
|
if localeIdentifier.hasPrefix("es") { return .spanish }
|
|
if localeIdentifier.hasPrefix("fr") { return .french }
|
|
if localeIdentifier.hasPrefix("de") { return .german }
|
|
if localeIdentifier.hasPrefix("it") { return .italian }
|
|
if localeIdentifier.hasPrefix("pt") { return .portugueseBrazil }
|
|
return .english
|
|
}
|
|
}
|
|
|
|
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 {
|
|
case weekComplete
|
|
case manual
|
|
|
|
var localizedKey: String {
|
|
switch self {
|
|
case .weekComplete: return "settings_sync_mode_week_complete"
|
|
case .manual: return "settings_sync_mode_manual"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum WeekExportStyle: String, CaseIterable {
|
|
case defaultStyle
|
|
case schoolTimetable
|
|
case vertical
|
|
|
|
var localizedKey: String {
|
|
switch self {
|
|
case .defaultStyle: return "share_export_style_default"
|
|
case .schoolTimetable: return "share_export_style_school"
|
|
case .vertical: return "share_export_style_vertical"
|
|
}
|
|
}
|
|
}
|