A new iOS widget extension shows the active exercise, its phase, and the work/rest countdown on the lock screen and in the Dynamic Island, driven by the run flow's live frames so locking the phone mid-set keeps the timer. The activity is seeded on open, refreshed on every page settle, dismissed when the flow is left, and cleared on next launch if stranded. Unifies the build-info stamping across all targets via a YAML anchor. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
56 lines
2.2 KiB
Swift
56 lines
2.2 KiB
Swift
import Foundation
|
|
import Observation
|
|
import SwiftData
|
|
|
|
/// 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
|
|
let watchBridge: PhoneConnectivityBridge
|
|
let workoutLauncher = WorkoutLauncher()
|
|
let workoutHealthDeleter = WorkoutHealthDeleter()
|
|
|
|
/// 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<Void, Never>?
|
|
|
|
init() {
|
|
let container = WorkoutsModelContainer.make()
|
|
self.container = container
|
|
self.syncEngine = SyncEngine(container: container)
|
|
let liveRunState = LiveRunState()
|
|
self.liveRunState = liveRunState
|
|
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
|
|
#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
|
|
}
|
|
}
|