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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user