Files
workouts/Shared/Model/Enums.swift
T
rzen 6e440317c4 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
2026-07-11 07:53:01 -04:00

248 lines
8.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ?? "")
}
}
}