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:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
+16 -13
View File
@@ -7,14 +7,17 @@ import Foundation
/// WatchConnectivity allows) keyed by stable ULIDs no name/date reconciliation.
enum WCPayload {
static let typeKey = "type"
static let splitsKey = "splits"
// PINNED VALUES: the constant names became `routinesKey` / `editingRoutineIDKey`,
// but their string VALUES stay `"splits"` / `"editingSplitID"` so an updated phone
// still interops with a not-yet-updated watch over WatchConnectivity.
static let routinesKey = "splits"
static let workoutsKey = "workouts"
static let workoutKey = "workout"
static let restSecondsKey = "restSeconds"
static let doneCountdownSecondsKey = "doneCountdownSeconds"
static let weightUnitKey = "weightUnit"
static let editingWorkoutIDKey = "editingWorkoutID"
static let editingSplitIDKey = "editingSplitID"
static let editingRoutineIDKey = "editingSplitID"
static let workoutUpdateType = "workoutUpdate" // watch phone (one workout)
static let requestSyncType = "requestSync" // watch phone (please push state)
@@ -23,31 +26,31 @@ enum WCPayload {
// MARK: - Phone Watch (application context: latest-state-wins)
/// `editingWorkoutID` / `editingSplitID` are an exclusive-edit lock: while the phone
/// has a workout's exercise (or a split) open in an editor, the watch parks any
/// `editingWorkoutID` / `editingRoutineID` are an exclusive-edit lock: while the phone
/// has a workout's exercise (or a routine) open in an editor, the watch parks any
/// matching run and locks re-entry, so only one device owns the run at a time. They're
/// part of the same latest-wins context absent keys mean "not editing" (lock clear).
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingSplitID: String?) -> [String: Any] {
static func encodeState(routines: [RoutineDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingRoutineID: String?) -> [String: Any] {
var dict: [String: Any] = [:]
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
if let s = try? DocumentCoder.encoder.encode(routines) { dict[routinesKey] = s }
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
dict[restSecondsKey] = restSeconds
dict[doneCountdownSecondsKey] = doneCountdownSeconds
dict[weightUnitKey] = weightUnit
if let editingWorkoutID { dict[editingWorkoutIDKey] = editingWorkoutID }
if let editingSplitID { dict[editingSplitIDKey] = editingSplitID }
if let editingRoutineID { dict[editingRoutineIDKey] = editingRoutineID }
return dict
}
/// `nil` means the payload carried splits that failed to decode (a schema mismatch
/// `nil` means the payload carried routines that failed to decode (a schema mismatch
/// between the two builds) distinct from an absent key or a legitimately empty
/// list, so the receiver can surface it instead of silently applying nothing.
static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument]? {
guard let data = dict[splitsKey] as? Data else { return [] }
return try? DocumentCoder.decoder.decode([SplitDocument].self, from: data)
static func decodeRoutines(_ dict: [String: Any]) -> [RoutineDocument]? {
guard let data = dict[routinesKey] as? Data else { return [] }
return try? DocumentCoder.decoder.decode([RoutineDocument].self, from: data)
}
/// See `decodeSplits` `nil` is a decode failure, not an empty list.
/// See `decodeRoutines` `nil` is a decode failure, not an empty list.
static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument]? {
guard let data = dict[workoutsKey] as? Data else { return [] }
return try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)
@@ -61,7 +64,7 @@ enum WCPayload {
static func decodeEditingWorkoutID(_ dict: [String: Any]) -> String? { dict[editingWorkoutIDKey] as? String }
static func decodeEditingSplitID(_ dict: [String: Any]) -> String? { dict[editingSplitIDKey] as? String }
static func decodeEditingRoutineID(_ dict: [String: Any]) -> String? { dict[editingRoutineIDKey] as? String }
// MARK: - Watch Phone (a single updated workout)