Files
workouts/Workouts/AppServices.swift
T
rzen 5ec5de9295 Add reminder notifications to schedules
- ScheduleDocument/Schedule gain optional reminderMinutes (minutes from
  midnight; decode-compatible, no schema bump)
- New Workout form gets a Reminder section (toggle + time picker, hidden
  for Now; footer warns when notifications are denied)
- ReminderPlanner (pure, tested) derives notification triggers: daily
  repeating, per-weekday repeating for fixed days, one-shot for once
- ReminderScheduler resyncs pending requests from the cache on every
  change, multiplexed onto onCacheChanged after the watch push; asks
  notification permission only once a reminder actually exists
2026-07-11 12:32:08 -04:00

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