The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
81 lines
3.7 KiB
Swift
81 lines
3.7 KiB
Swift
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<Void, Never>?
|
|
|
|
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
|
|
}
|
|
}
|