Restructure into a three-tab app with Progress, goals, and Meditation
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
This commit is contained in:
@@ -34,9 +34,9 @@ final class WatchConnectivityBridge: NSObject {
|
||||
/// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching
|
||||
/// run (popping out of its progress view) and blocks re-entry, so the phone owns the
|
||||
/// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID`
|
||||
/// matches a run by its workout id; `editingSplitID` matches any run by its `splitID`.
|
||||
/// matches a run by its workout id; `editingRoutineID` matches any run by its `routineID`.
|
||||
private(set) var editingWorkoutID: String?
|
||||
private(set) var editingSplitID: String?
|
||||
private(set) var editingRoutineID: String?
|
||||
|
||||
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
|
||||
/// frame we *receive*, so the two devices share one increasing per-run sequence and either
|
||||
@@ -101,16 +101,16 @@ final class WatchConnectivityBridge: NSObject {
|
||||
}
|
||||
|
||||
/// Apply the last application context the system holds for us (settings, edit
|
||||
/// locks, and the authoritative splits + workouts sets). Idempotent, so it's safe
|
||||
/// locks, and the authoritative routines + workouts sets). Idempotent, so it's safe
|
||||
/// to call both eagerly at launch and again when activation completes.
|
||||
private func applyReceivedContext() {
|
||||
guard let session else { return }
|
||||
let ctx = session.receivedApplicationContext
|
||||
Self.log.info("applyReceivedContext: activation=\(session.activationState.rawValue) keys=\(ctx.keys.sorted().joined(separator: ","), privacy: .public)")
|
||||
applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
|
||||
applyState(WCPayload.decodeRoutines(ctx), workouts: WCPayload.decodeWorkouts(ctx))
|
||||
applySettings(ctx)
|
||||
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
|
||||
editingSplitID = WCPayload.decodeEditingSplitID(ctx)
|
||||
editingRoutineID = WCPayload.decodeEditingRoutineID(ctx)
|
||||
}
|
||||
|
||||
/// Apply a decoded state push. `nil` (decode failure — the phone runs a build with
|
||||
@@ -118,14 +118,14 @@ final class WatchConnectivityBridge: NSObject {
|
||||
/// against a bogus empty set nor silently show stale data forever. The upsert/prune
|
||||
/// itself is delegated to the pure, session-free `WatchCacheApplier` seam so the
|
||||
/// apply/prune contract is unit-testable.
|
||||
private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) {
|
||||
guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else {
|
||||
Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
|
||||
private func applyState(_ routines: [RoutineDocument]?, workouts: [WorkoutDocument]?) {
|
||||
guard WatchCacheApplier.apply(routines: routines, workouts: workouts, into: context) else {
|
||||
Self.log.error("applyState: payload failed to decode (routines=\(routines == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
|
||||
schemaMismatch = true
|
||||
return
|
||||
}
|
||||
schemaMismatch = false
|
||||
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
|
||||
Self.log.info("applyState: applied \(routines?.count ?? 0) routines, \(workouts?.count ?? 0) workouts")
|
||||
lastSyncDate = Date()
|
||||
onWorkoutsChanged?()
|
||||
}
|
||||
@@ -277,7 +277,7 @@ final class WatchConnectivityBridge: NSObject {
|
||||
// MARK: - Cache apply/prune seam
|
||||
|
||||
/// The pure, session-free core of the phone→watch state apply: it upserts the authoritative
|
||||
/// splits/workouts into the watch's SwiftData cache and prunes anything the phone no longer
|
||||
/// routines/workouts into the watch's SwiftData cache and prunes anything the phone no longer
|
||||
/// sends. Split out of `WatchConnectivityBridge` (which wraps `WCSession`) so the apply/prune
|
||||
/// contract — including the authoritative-empty prune and the nil-decode skip — is unit-testable
|
||||
/// against an in-memory `ModelContext` without a live WatchConnectivity session.
|
||||
@@ -288,31 +288,31 @@ enum WatchCacheApplier {
|
||||
/// applied, `false` when skipped, so the caller can log / stamp `lastSyncDate` accordingly.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
static func apply(splits: [SplitDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
|
||||
guard let splits, let workouts else { return false }
|
||||
apply(splits: splits, workouts: workouts, into: context)
|
||||
static func apply(routines: [RoutineDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
|
||||
guard let routines, let workouts else { return false }
|
||||
apply(routines: routines, workouts: workouts, into: context)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Upsert every split/workout the phone sent, then prune anything it *didn't*. Both sets are
|
||||
/// Upsert every routine/workout the phone sent, then prune anything it *didn't*. Both sets are
|
||||
/// authoritative, so an authoritative empty push clears rows the phone no longer has (a deleted
|
||||
/// split; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
|
||||
/// routine; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
|
||||
/// from the ~24h window). On first launch the cache is empty, so the prune is a harmless no-op.
|
||||
/// The watch never originates a split/workout, so pruning can't lose local-only data.
|
||||
/// The watch never originates a routine/workout, so pruning can't lose local-only data.
|
||||
@MainActor
|
||||
static func apply(splits: [SplitDocument], workouts: [WorkoutDocument], into context: ModelContext) {
|
||||
var liveSplitIDs = Set<String>()
|
||||
for s in splits {
|
||||
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
||||
liveSplitIDs.insert(s.id)
|
||||
static func apply(routines: [RoutineDocument], workouts: [WorkoutDocument], into context: ModelContext) {
|
||||
var liveRoutineIDs = Set<String>()
|
||||
for s in routines {
|
||||
CacheMapper.upsertRoutine(s, relativePath: s.relativePath, into: context)
|
||||
liveRoutineIDs.insert(s.id)
|
||||
}
|
||||
var liveWorkoutIDs = Set<String>()
|
||||
for w in workouts {
|
||||
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
||||
liveWorkoutIDs.insert(w.id)
|
||||
}
|
||||
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
||||
if let allRoutines = try? context.fetch(FetchDescriptor<Routine>()) {
|
||||
for s in allRoutines where !liveRoutineIDs.contains(s.id) { context.delete(s) }
|
||||
}
|
||||
if let allWorkouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
||||
for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
|
||||
@@ -359,21 +359,21 @@ extension WatchConnectivityBridge: WCSessionDelegate {
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
let splits = WCPayload.decodeSplits(applicationContext)
|
||||
let routines = WCPayload.decodeRoutines(applicationContext)
|
||||
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||
let rest = WCPayload.decodeRestSeconds(applicationContext)
|
||||
let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
|
||||
let unit = WCPayload.decodeWeightUnit(applicationContext)
|
||||
let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext)
|
||||
let editingSplitID = WCPayload.decodeEditingSplitID(applicationContext)
|
||||
let editingRoutineID = WCPayload.decodeEditingRoutineID(applicationContext)
|
||||
Task { @MainActor in
|
||||
self.applyState(splits, workouts: workouts)
|
||||
self.applyState(routines, workouts: workouts)
|
||||
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
|
||||
if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
|
||||
if let unit { UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey) }
|
||||
// Absent keys mean "not editing" — set unconditionally so the lock clears.
|
||||
self.editingWorkoutID = editingWorkoutID
|
||||
self.editingSplitID = editingSplitID
|
||||
self.editingRoutineID = editingRoutineID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user