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:
@@ -5,7 +5,7 @@ import WatchConnectivity
|
||||
|
||||
/// Phone side of the iPhone↔Watch bridge. The phone owns iCloud Drive; the watch
|
||||
/// is a thin remote that round-trips through it:
|
||||
/// • Phone → Watch: pushes all splits + recent workouts as the latest
|
||||
/// • Phone → Watch: pushes all routines + recent workouts as the latest
|
||||
/// application context whenever the cache changes (local or remote).
|
||||
/// • Watch → Phone: receives an updated `WorkoutDocument` and applies it via the
|
||||
/// SyncEngine write path (file → observer → cache → push back).
|
||||
@@ -19,12 +19,12 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
private var session: WCSession?
|
||||
|
||||
/// Exclusive-edit lock published to the watch. While the phone has a workout's
|
||||
/// exercise (or a split) open in an editor, the watch parks any matching run and
|
||||
/// exercise (or a routine) open in an editor, the watch parks any matching run and
|
||||
/// blocks re-entry — so the two devices never drive the same run at once. Included in
|
||||
/// every `pushAll` (the latest-wins context replaces wholesale, so a push that omitted
|
||||
/// them would clear the lock prematurely).
|
||||
private(set) var editingWorkoutID: String?
|
||||
private(set) var editingSplitID: String?
|
||||
private(set) var editingRoutineID: String?
|
||||
|
||||
/// While true (scene backgrounded), the edit locks are *published* to the watch as
|
||||
/// cleared without being forgotten locally. An editor left open in a pocketed — or
|
||||
@@ -70,7 +70,7 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
syncEngine.onCacheChanged = { [weak self] in self?.pushAll() }
|
||||
}
|
||||
|
||||
/// Sends the current splits + most-recent workouts to the watch.
|
||||
/// Sends the current routines + most-recent workouts to the watch.
|
||||
func pushAll() {
|
||||
guard let session, session.activationState == .activated, session.isPaired,
|
||||
session.isWatchAppInstalled else {
|
||||
@@ -78,7 +78,7 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
|
||||
let routines = (try? context.fetch(FetchDescriptor<Routine>(sortBy: [SortDescriptor(\.order)]))) ?? []
|
||||
|
||||
// The watch only needs what it can act on: every active run (in-progress /
|
||||
// not-started) plus recently-completed ones, kept ~24h so a run that just
|
||||
@@ -107,24 +107,24 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
||||
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
|
||||
let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue
|
||||
let splitDocs = splits.map(SplitDocument.init(from:))
|
||||
let routineDocs = routines.map(RoutineDocument.init(from:))
|
||||
func encode(_ included: [Workout]) -> [String: Any] {
|
||||
WCPayload.encodeState(
|
||||
splits: splitDocs,
|
||||
routines: routineDocs,
|
||||
workouts: included.map(WorkoutDocument.init(from:)),
|
||||
restSeconds: restSeconds,
|
||||
doneCountdownSeconds: doneCountdownSeconds,
|
||||
weightUnit: weightUnit,
|
||||
editingWorkoutID: locksSuspended ? nil : editingWorkoutID,
|
||||
editingSplitID: locksSuspended ? nil : editingSplitID
|
||||
editingRoutineID: locksSuspended ? nil : editingRoutineID
|
||||
)
|
||||
}
|
||||
do {
|
||||
let payload = encode(workouts)
|
||||
try session.updateApplicationContext(payload)
|
||||
let bytes = ((payload[WCPayload.splitsKey] as? Data)?.count ?? 0)
|
||||
let bytes = ((payload[WCPayload.routinesKey] as? Data)?.count ?? 0)
|
||||
+ ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0)
|
||||
Self.log.info("pushAll: \(splits.count) splits, \(workouts.count) workouts (\(bytes) bytes)")
|
||||
Self.log.info("pushAll: \(routines.count) routines, \(workouts.count) workouts (\(bytes) bytes)")
|
||||
} catch {
|
||||
// Realistically payload-too-large. The context is the watch's lifeline — a
|
||||
// dropped push means it silently never hears about this state again — so
|
||||
@@ -145,7 +145,7 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
func setLocksSuspended(_ suspended: Bool) {
|
||||
guard locksSuspended != suspended else { return }
|
||||
locksSuspended = suspended
|
||||
if editingWorkoutID != nil || editingSplitID != nil { pushAll() }
|
||||
if editingWorkoutID != nil || editingRoutineID != nil { pushAll() }
|
||||
}
|
||||
|
||||
/// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor.
|
||||
@@ -157,11 +157,11 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
pushAll()
|
||||
}
|
||||
|
||||
/// Mark (or clear, with `nil`) the split currently open in a phone editor. The watch
|
||||
/// parks any active run sourced from that split (matched by `splitID`).
|
||||
func setEditingSplit(_ id: String?) {
|
||||
guard editingSplitID != id else { return }
|
||||
editingSplitID = id
|
||||
/// Mark (or clear, with `nil`) the routine currently open in a phone editor. The watch
|
||||
/// parks any active run sourced from that routine (matched by `routineID`).
|
||||
func setEditingRoutine(_ id: String?) {
|
||||
guard editingRoutineID != id else { return }
|
||||
editingRoutineID = id
|
||||
pushAll()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user