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() /// 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) // 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() // 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 } }