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
248 lines
8.5 KiB
Swift
248 lines
8.5 KiB
Swift
import Foundation
|
||
|
||
/// Completion state of a workout or an individual exercise log. Persisted as its
|
||
/// raw string in both the JSON documents and the SwiftData cache.
|
||
enum WorkoutStatus: String, CaseIterable, Codable, Sendable {
|
||
case notStarted
|
||
case inProgress
|
||
case completed
|
||
case skipped
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .notStarted: "Not Started"
|
||
case .inProgress: "In Progress"
|
||
case .completed: "Completed"
|
||
case .skipped: "Skipped"
|
||
}
|
||
}
|
||
|
||
var name: String { displayName }
|
||
}
|
||
|
||
/// How an exercise's effort is measured. Persisted as its raw `Int` value.
|
||
enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||
case none = 0
|
||
case weight = 1
|
||
case duration = 2
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .none: "None"
|
||
case .weight: "Weight"
|
||
case .duration: "Duration"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 `RoutineDocument`; the `HKWorkoutActivityType`
|
||
/// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum
|
||
/// HealthKit-free.
|
||
enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||
case traditionalStrength = 0 // default
|
||
case functionalStrength = 1
|
||
case hiit = 2
|
||
case coreTraining = 3
|
||
case cardio = 4
|
||
case cycling = 5
|
||
case warmUp = 6
|
||
case stretching = 7
|
||
case mindAndBody = 8
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .traditionalStrength: "Strength Training"
|
||
case .functionalStrength: "Functional Strength"
|
||
case .hiit: "HIIT"
|
||
case .coreTraining: "Core Training"
|
||
case .cardio: "Cardio"
|
||
case .cycling: "Cycling"
|
||
case .warmUp: "Warm-Up"
|
||
case .stretching: "Stretching"
|
||
case .mindAndBody: "Mind & Body"
|
||
}
|
||
}
|
||
|
||
var systemImage: String {
|
||
switch self {
|
||
case .traditionalStrength: "dumbbell.fill"
|
||
case .functionalStrength: "figure.strengthtraining.functional"
|
||
case .hiit: "figure.highintensity.intervaltraining"
|
||
case .coreTraining: "figure.core.training"
|
||
case .cardio: "figure.run"
|
||
case .cycling: "figure.outdoor.cycle"
|
||
case .warmUp: "figure.arms.open"
|
||
case .stretching: "figure.flexibility"
|
||
case .mindAndBody: "figure.mind.and.body"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where a workout's health metrics came from: real watch sensors, or — for records
|
||
/// written by app versions up to 2.x — a phone-side MET estimate made when no watch
|
||
/// session ran. The phone no longer writes estimates (recording is watch-only), but
|
||
/// `phoneEstimate` is retained so legacy documents on disk still decode. Persisted in
|
||
/// `WorkoutMetrics`.
|
||
enum MetricSource: String, Codable, Sendable {
|
||
case watch
|
||
case phoneEstimate
|
||
}
|
||
|
||
/// Display unit for weights. The stored weight values are unit-less numbers and
|
||
/// are never rewritten when this changes — switching only relabels what's shown.
|
||
enum WeightUnit: String, CaseIterable, Codable, Sendable {
|
||
case lb
|
||
case kg
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .lb: "Pounds (lb)"
|
||
case .kg: "Kilograms (kg)"
|
||
}
|
||
}
|
||
|
||
var abbreviation: String {
|
||
switch self {
|
||
case .lb: "lb"
|
||
case .kg: "kg"
|
||
}
|
||
}
|
||
|
||
/// Render a stored weight value with the current unit's label, trimming a
|
||
/// trailing ".0" so whole values stay clean: "45 lb", "42.5 kg".
|
||
func format(_ value: Double) -> String {
|
||
let text = value.truncatingRemainder(dividingBy: 1) == 0
|
||
? String(Int(value))
|
||
: String(value)
|
||
return "\(text) \(abbreviation)"
|
||
}
|
||
|
||
/// 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 ?? "")
|
||
}
|
||
}
|
||
}
|