Files
FamilyMealPlanner/MealMood/ViewModels/SettingsViewModel.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

147 lines
5.1 KiB
Swift

import SwiftUI
import SwiftData
import EventKit
@MainActor
final class SettingsViewModel: ObservableObject {
@Published var availableCalendars: [EKCalendar] = []
@Published var showCalendarPermissionAlert: Bool = false
@Published var showToast: Bool = false
@Published var toastMessage: String = ""
var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-"
}
var appBuild: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "-"
}
func loadCalendars() {
availableCalendars = CalendarService.shared.availableCalendars()
}
@discardableResult
func ensureValidCalendarSelection(settings: AppSettings) -> Bool {
loadCalendars()
guard !availableCalendars.isEmpty else {
settings.calendarId = nil
settings.syncEnabled = false
showToastMessage(String(localized: "toast_calendar_unavailable"))
return false
}
if let selected = settings.calendarId,
availableCalendars.contains(where: { $0.calendarIdentifier == selected }) {
return true
}
settings.calendarId = availableCalendars.first?.calendarIdentifier
return true
}
func requestCalendarAccess() async -> Bool {
let granted = await CalendarService.shared.requestAccess()
if !granted {
showCalendarPermissionAlert = true
} else {
loadCalendars()
}
return granted
}
func openSystemSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) {
guard let plan = plan, settings.syncEnabled else { return }
CalendarService.shared.updateEventsTime(
slots: plan.slotList,
weekStartDate: plan.weekStartDate,
settings: settings
)
}
func resetAllData(context: ModelContext) {
do {
let settingsDescriptor = FetchDescriptor<AppSettings>()
let targetSettings: AppSettings
if let existingSettings = try context.fetch(settingsDescriptor).first {
targetSettings = existingSettings
} else {
let created = AppSettings()
context.insert(created)
targetSettings = created
}
// Phase 1: switch app flow out of Home before destructive deletes.
resetSettings(targetSettings)
try context.save()
Task { @MainActor in
do {
try? await Task.sleep(nanoseconds: 200_000_000)
try deleteAll(of: WeekPlan.self, in: context)
try deleteAll(of: Dish.self, in: context)
try deleteAll(of: Tag.self, in: context)
DefaultDataService.createDefaultTags(context: context, saveImmediately: false)
// Ensure a true "fresh launch" onboarding flow.
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
try context.save()
} catch {
CrashlyticsService.record(error, context: "reset_all_data")
#if DEBUG
print("Failed to complete reset all data: \(error)")
#endif
}
}
} catch {
CrashlyticsService.record(error, context: "reset_all_data_start")
#if DEBUG
print("Failed to start reset all data: \(error)")
#endif
}
}
private func deleteAll<T: PersistentModel>(of type: T.Type, in context: ModelContext) throws {
let descriptor = FetchDescriptor<T>()
let models = try context.fetch(descriptor)
for model in models {
context.delete(model)
}
}
private func showToastMessage(_ message: String) {
toastMessage = message
withAnimation(.easeInOut(duration: 0.2)) {
showToast = true
}
}
private func resetSettings(_ settings: AppSettings) {
let defaults = AppSettings()
settings.mealWindows = defaults.mealWindows
settings.includeWeekends = defaults.includeWeekends
settings.language = defaults.language
settings.calendarId = defaults.calendarId
settings.syncEnabled = defaults.syncEnabled
settings.syncMode = defaults.syncMode
settings.lunchTime = defaults.lunchTime
settings.dinnerTime = defaults.dinnerTime
settings.eventDuration = defaults.eventDuration
settings.eventPrefix = defaults.eventPrefix
settings.reminderMinutesBefore = defaults.reminderMinutesBefore
settings.weekExportStyle = defaults.weekExportStyle
settings.iCloudSyncEnabled = false
settings.onboardingCompleted = false
}
}