import Foundation import Observation import SwiftData import IndieBackup /// Composition root for the iOS app. Owns the SwiftData cache container and the /// iCloud sync engine, and drives the one-shot launch sequence. Injected into the /// view tree via `.environment(...)`. @Observable @MainActor final class AppServices { let container: ModelContainer let syncEngine: SyncEngine /// Local ZIP backup/restore of the iCloud document tree (`backupRoot` = the /// container's `Documents/`). Restores rebuild the SwiftData cache via /// `SyncEngine.rebuildCache()`; see `AppBackupConfiguration`. let backupController: BackupController let watchBridge: PhoneConnectivityBridge let workoutLauncher = WorkoutLauncher() let workoutHealthDeleter = WorkoutHealthDeleter() /// Keeps pending local notifications in sync with the schedules' reminder times /// (an idempotent full resync on every cache change). let reminderScheduler: ReminderScheduler /// Speaks exercise instructions aloud (library Speak button + hands-free workout cues). /// Shared so a new exercise's cue cancels whatever the last screen was still saying. let speechAnnouncer = SpeechAnnouncer() /// Owns the active-workout Live Activity (lock screen + Dynamic Island). Driven by the run /// flow's `LiveProgress` frames, so locking the phone mid-set doesn't lose the timer. let liveActivity = WorkoutLiveActivityController() /// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted. let liveRunState: LiveRunState private var bootstrapTask: Task? init() { let container = WorkoutsModelContainer.make() self.container = container let syncEngine = SyncEngine(container: container) self.syncEngine = syncEngine self.backupController = BackupController( configuration: AppBackupConfiguration(syncEngine: syncEngine) ) let liveRunState = LiveRunState() self.liveRunState = liveRunState self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState) self.reminderScheduler = ReminderScheduler(container: container) // Launch the wrist session only when a run actually begins (the first // exercise leaves `.notStarted` on the phone) — creating a workout alone is // just a peek and must not start anything on the watch. Watch-originated // starts don't fire this hook; the watch already owns its session. syncEngine.onWorkoutBecameActive = { [weak self] doc in guard let self else { return } let routine = doc.routineID.flatMap { id in CacheMapper.fetchRoutine(id: syncEngine.currentRoutineID(for: id), in: container.mainContext) } let activityType = routine?.activityTypeEnum ?? .traditionalStrength self.workoutLauncher.launchWatchWorkout(activityType: activityType.hkActivityType) } #if DEBUG if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) } #endif } /// Launch step: resolve iCloud and reconcile the cache. Idempotent — repeated /// callers await the same one-shot task. func bootstrap() async { if let bootstrapTask { await bootstrapTask.value; return } let task = Task { @MainActor [weak self] in guard let self else { return } // Clear any Live Activity stranded by a previous launch (app killed mid-run). await self.liveActivity.endStaleActivities() await self.syncEngine.connect() self.watchBridge.activate() // `activate()` just claimed `onCacheChanged` for the watch push — multiplex // rather than replace, so reminder reconciliation rides the same signal. let watchPush = self.syncEngine.onCacheChanged self.syncEngine.onCacheChanged = { [weak self] in watchPush?() self?.reminderScheduler.scheduleReconcile() } // Initial post-bootstrap resync (covers changes that happened while // the app wasn't running — e.g. schedules edited on another device). self.reminderScheduler.scheduleReconcile() // Past the iCloud gate: request the workout-share scope so the user can still // delete legacy phone-estimate workouts from Health when deleting them here. self.workoutHealthDeleter.authorizeIfNeeded() } bootstrapTask = task await task.value } }