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
+128 -2
View File
@@ -35,9 +35,9 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
}
}
/// The kind of training a split represents used to tag the watch's Apple Health
/// The kind of training a routine represents used to tag the watch's Apple Health
/// workout so it lands in the right category (and credits the rings correctly).
/// Persisted as its raw `Int` on `SplitDocument`; the `HKWorkoutActivityType`
/// Persisted as its raw `Int` on `RoutineDocument`; the `HKWorkoutActivityType`
/// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum
/// HealthKit-free.
enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
@@ -49,6 +49,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case cycling = 5
case warmUp = 6
case stretching = 7
case mindAndBody = 8
var displayName: String {
switch self {
@@ -60,6 +61,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .cycling: "Cycling"
case .warmUp: "Warm-Up"
case .stretching: "Stretching"
case .mindAndBody: "Mind & Body"
}
}
@@ -73,6 +75,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .cycling: "figure.outdoor.cycle"
case .warmUp: "figure.arms.open"
case .stretching: "figure.flexibility"
case .mindAndBody: "figure.mind.and.body"
}
}
}
@@ -119,3 +122,126 @@ enum WeightUnit: String, CaseIterable, Codable, Sendable {
/// Render a stored weight value with the current unit's label, e.g. "135 lb".
func format(_ value: Int) -> String { "\(value) \(abbreviation)" }
}
// MARK: - Goals
/// The predefined practice-goal categories. Deliberately a small, closed set
/// schedules reference them by raw value, so raw values are stable persisted data.
enum GoalKind: String, CaseIterable, Codable, Sendable, Identifiable {
// Declaration order IS the initial board order (before any user reorder):
// the day reads top-to-bottom morning mobility, then cardio, then strength,
// with mindfulness closing it out.
case mobility, cardio, strength, mindfulness
var id: String { rawValue }
var displayName: String {
switch self {
case .strength: "Strength"
// Deliberately double-barreled: the same practice serves performance
// ("endurance") and health ("cardio") motivations the name excludes neither.
case .cardio: "Endurance/Cardio"
case .mobility: "Mobility"
case .mindfulness: "Mindfulness"
}
}
var systemImage: String {
switch self {
case .strength: "dumbbell.fill"
case .cardio: "figure.run"
case .mobility: "figure.flexibility"
case .mindfulness: "brain.head.profile"
}
}
/// A hue name `Color.color(from:)` understands. Four distinct hues so the goals
/// read apart at a glance.
var colorName: String {
switch self {
case .strength: "red"
case .cardio: "orange"
case .mobility: "teal"
case .mindfulness: "purple"
}
}
}
extension GoalKind {
static let orderDefaultsKey = "goalKindOrder"
/// All cases in the user's chosen order: stored raw values first (unknown ones
/// dropped, duplicates collapsed), then any cases missing from the stored list in
/// declaration order so newly added kinds appear automatically.
static func orderedCases(defaults: UserDefaults = .standard) -> [GoalKind] {
let stored = defaults.stringArray(forKey: orderDefaultsKey) ?? []
var ordered: [GoalKind] = []
var seen = Set<GoalKind>()
for raw in stored {
if let kind = GoalKind(rawValue: raw), seen.insert(kind).inserted {
ordered.append(kind)
}
}
for kind in GoalKind.allCases where !seen.contains(kind) {
ordered.append(kind)
}
return ordered
}
/// Persist the display order as `[String]` of raw values.
static func saveOrder(_ kinds: [GoalKind], defaults: UserDefaults = .standard) {
defaults.set(kinds.map(\.rawValue), forKey: orderDefaultsKey)
}
}
// MARK: - Schedules
/// How often a schedule expects its routine. Raw values are persisted in ScheduleDocument.
enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable {
case once, daily, fixedDays, frequency
var displayName: String {
switch self {
case .once: "Once"
case .daily: "Daily"
case .fixedDays: "Fixed Days"
case .frequency: "Times per Week"
}
}
}
extension ScheduleRecurrence {
/// Human-readable recurrence description shared by `ScheduleDocument` and the
/// `Schedule` cache entity, so both render identically. `weekdays` uses Calendar
/// weekday numbers (1 = Sun 7 = Sat); `timesPerWeek` is the frequency count;
/// `date` is the one-off day (`.once` only). A degenerate case (fixed days with no
/// weekdays, a nil count or date) falls back to the recurrence's own `displayName`.
func summary(weekdays: [Int]?, timesPerWeek: Int?, date: Date?) -> String {
switch self {
case .once:
return date?.formatDate() ?? displayName // "Jul 12, 2026" / "Once"
case .daily:
return displayName // "Daily"
case .fixedDays:
// A fixed Gregorian calendar's `shortWeekdaySymbols` is 0-indexed from
// Sunday, so weekday number `n` maps to index `n - 1` regardless of locale.
let symbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
let names = (weekdays ?? []).sorted().compactMap { n -> String? in
(1...7).contains(n) ? symbols[n - 1] : nil
}
return names.isEmpty ? displayName : Self.joined(names)
case .frequency:
return "\(timesPerWeek ?? 0)× per week"
}
}
/// Join weekday short names conversationally: "Mon", "Mon & Thu", "Mon, Wed & Fri".
private static func joined(_ names: [String]) -> String {
switch names.count {
case 0: return ""
case 1: return names[0]
case 2: return "\(names[0]) & \(names[1])"
default: return names.dropLast().joined(separator: ", ") + " & " + (names.last ?? "")
}
}
}