Files
FamilyMealPlanner/MealMood/Models/AppSettings.swift
T
alexandrev-tibco 4ffa15b06a 2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container).
SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device
sync for the same account instead:

- Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5,
  inline defaults on every attribute, WeekPlan.slots stored as optional
  relationship (name preserved → lightweight migration) with non-optional
  slotList facade; ~73 call sites renamed.
- Container: cloudKitDatabase .automatic, falling back to the local-only
  store when CloudKit is unavailable; failures recorded to Crashlytics.
- Entitlements: iCloud CloudKit service (container already existed);
  remote-notification background mode for push-driven sync.
- Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is
  active — kept only for 1.x devices.
- DeduplicationService collapses cross-device duplicates deterministically on
  launch (settings singleton, default tags with tagId remapping, same-week
  plans).
- Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit
  (PremiumSyncService + Transaction.updates) remains the source of truth and
  reconciles on every launch/foreground.

All 21 unit tests pass, including ICloudSyncPremiumIsolationTests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-12 18:13:45 +02:00

258 lines
8.2 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?
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 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"
}
}
}