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:
@@ -4,7 +4,7 @@ import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Round-trips every `WCPayload` wire shape — the iPhone↔Watch bridge's only
|
||||
/// serialization boundary. Splits/workouts ride as JSON `Data` blobs (via
|
||||
/// serialization boundary. Routines/workouts ride as JSON `Data` blobs (via
|
||||
/// `DocumentCoder`, ISO-8601), settings and edit locks as native plist scalars, and
|
||||
/// the ephemeral live-run frames as native values (so their timers keep sub-second
|
||||
/// precision). A field dropped or a `nil`-vs-empty confusion here silently desyncs
|
||||
@@ -14,9 +14,9 @@ struct WCPayloadRoundTripTests {
|
||||
|
||||
private static let ts = Date(timeIntervalSince1970: 1_700_000_000) // whole second: ISO-8601 safe
|
||||
|
||||
private func split(id: String, name: String) -> SplitDocument {
|
||||
SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion, id: id, name: name, color: "blue",
|
||||
private func routine(id: String, name: String) -> RoutineDocument {
|
||||
RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion, id: id, name: name, color: "blue",
|
||||
systemImage: "dumbbell.fill", order: 0, createdAt: Self.ts, updatedAt: Self.ts,
|
||||
exercises: [ExerciseDocument(id: "EX-\(id)", name: "Bench Press", order: 0, sets: 4,
|
||||
reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
|
||||
@@ -27,7 +27,7 @@ struct WCPayloadRoundTripTests {
|
||||
|
||||
private func workout(id: String) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "S", splitName: "Push",
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "S", routineName: "Push",
|
||||
start: Self.ts, end: Self.ts, status: WorkoutStatus.completed.rawValue,
|
||||
createdAt: Self.ts, updatedAt: Self.ts,
|
||||
logs: [WorkoutLogDocument(id: "L-\(id)", exerciseName: "Bench Press", order: 0, sets: 4,
|
||||
@@ -40,43 +40,43 @@ struct WCPayloadRoundTripTests {
|
||||
|
||||
// MARK: - Phone → Watch state + settings
|
||||
|
||||
@Test func stateRoundTripsSplitsWorkoutsAndSettings() {
|
||||
let splits = [split(id: "SP1", name: "Upper Body"), split(id: "SP2", name: "Lower Body")]
|
||||
@Test func stateRoundTripsRoutinesWorkoutsAndSettings() {
|
||||
let routines = [routine(id: "SP1", name: "Upper Body"), routine(id: "SP2", name: "Lower Body")]
|
||||
let workouts = [workout(id: "01WKA"), workout(id: "01WKB")]
|
||||
let dict = WCPayload.encodeState(
|
||||
splits: splits, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5,
|
||||
weightUnit: "kg", editingWorkoutID: "01WKA", editingSplitID: "SP1"
|
||||
routines: routines, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5,
|
||||
weightUnit: "kg", editingWorkoutID: "01WKA", editingRoutineID: "SP1"
|
||||
)
|
||||
|
||||
#expect(WCPayload.decodeSplits(dict) == splits)
|
||||
#expect(WCPayload.decodeRoutines(dict) == routines)
|
||||
#expect(WCPayload.decodeWorkouts(dict) == workouts)
|
||||
#expect(WCPayload.decodeRestSeconds(dict) == 90)
|
||||
#expect(WCPayload.decodeDoneCountdownSeconds(dict) == 5)
|
||||
#expect(WCPayload.decodeWeightUnit(dict) == "kg")
|
||||
#expect(WCPayload.decodeEditingWorkoutID(dict) == "01WKA")
|
||||
#expect(WCPayload.decodeEditingSplitID(dict) == "SP1")
|
||||
#expect(WCPayload.decodeEditingRoutineID(dict) == "SP1")
|
||||
}
|
||||
|
||||
/// Nil edit locks mean "not editing" → the keys are absent, so the decoders return
|
||||
/// nil (the receiver clears the lock). An empty splits/workouts list is legitimately
|
||||
/// nil (the receiver clears the lock). An empty routines/workouts list is legitimately
|
||||
/// empty — it still encodes a key and decodes back to `[]`, never nil.
|
||||
@Test func stateOmitsNilEditLocksAndKeepsEmptyListsDistinct() {
|
||||
let dict = WCPayload.encodeState(
|
||||
splits: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3,
|
||||
weightUnit: "lb", editingWorkoutID: nil, editingSplitID: nil
|
||||
routines: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3,
|
||||
weightUnit: "lb", editingWorkoutID: nil, editingRoutineID: nil
|
||||
)
|
||||
#expect(WCPayload.decodeEditingWorkoutID(dict) == nil)
|
||||
#expect(WCPayload.decodeEditingSplitID(dict) == nil)
|
||||
#expect(WCPayload.decodeEditingRoutineID(dict) == nil)
|
||||
// Authoritative empty push: an empty (present) list decodes to [], not nil.
|
||||
#expect(WCPayload.decodeSplits(dict) == [])
|
||||
#expect(WCPayload.decodeRoutines(dict) == [])
|
||||
#expect(WCPayload.decodeWorkouts(dict) == [])
|
||||
}
|
||||
|
||||
/// An absent splits/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is
|
||||
/// An absent routines/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is
|
||||
/// reserved for a genuine decode failure below.
|
||||
@Test func absentSplitsWorkoutsKeysDecodeToEmpty() {
|
||||
@Test func absentRoutinesWorkoutsKeysDecodeToEmpty() {
|
||||
let empty: [String: Any] = [:]
|
||||
#expect(WCPayload.decodeSplits(empty) == [])
|
||||
#expect(WCPayload.decodeRoutines(empty) == [])
|
||||
#expect(WCPayload.decodeWorkouts(empty) == [])
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ struct WCPayloadRoundTripTests {
|
||||
/// pruning against a bogus set. This must stay distinct from an empty list.
|
||||
@Test func corruptStateDecodesToNilNotEmpty() {
|
||||
let garbage = Data([0xFF, 0xFE, 0x00, 0x01])
|
||||
let dict: [String: Any] = [WCPayload.splitsKey: garbage, WCPayload.workoutsKey: garbage]
|
||||
#expect(WCPayload.decodeSplits(dict) == nil)
|
||||
let dict: [String: Any] = [WCPayload.routinesKey: garbage, WCPayload.workoutsKey: garbage]
|
||||
#expect(WCPayload.decodeRoutines(dict) == nil)
|
||||
#expect(WCPayload.decodeWorkouts(dict) == nil)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user