Add the exercise reference library, animated exercise figures, and exercise categories
Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
This commit is contained in:
@@ -46,6 +46,10 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderWeeks: Int
|
||||
/// Raw `ExerciseCategory`; nil → main circuit. Like `activityType`, deliberately
|
||||
/// NOT schema-bumped: it only affects how the split screen groups its list, so an
|
||||
/// older app dropping it on rewrite beats quarantining the routine.
|
||||
var category: Int?
|
||||
}
|
||||
|
||||
// MARK: - Workout
|
||||
|
||||
@@ -64,12 +64,13 @@ final class Exercise {
|
||||
var durationTotalSeconds: Int = 0
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderTimeIntervalWeeks: Int = 2
|
||||
var categoryRaw: Int = ExerciseCategory.main.rawValue
|
||||
|
||||
var split: Split?
|
||||
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
|
||||
weightReminderTimeIntervalWeeks: Int) {
|
||||
weightReminderTimeIntervalWeeks: Int, categoryRaw: Int = ExerciseCategory.main.rawValue) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.order = order
|
||||
@@ -80,6 +81,7 @@ final class Exercise {
|
||||
self.durationTotalSeconds = durationTotalSeconds
|
||||
self.weightLastUpdated = weightLastUpdated
|
||||
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
|
||||
self.categoryRaw = categoryRaw
|
||||
}
|
||||
|
||||
var loadTypeEnum: LoadType {
|
||||
@@ -87,6 +89,27 @@ final class Exercise {
|
||||
set { loadType = newValue.rawValue }
|
||||
}
|
||||
|
||||
var categoryEnum: ExerciseCategory {
|
||||
get { ExerciseCategory(rawValue: categoryRaw) ?? .main }
|
||||
set { categoryRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// One-line plan summary for list rows — duration exercises show their hold time
|
||||
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
|
||||
/// keep the full "3 × 10 × 40 lb".
|
||||
func planSummary(weightUnit: WeightUnit) -> String {
|
||||
switch loadTypeEnum {
|
||||
case .duration:
|
||||
let m = durationMinutes, s = durationSeconds
|
||||
let hold = m > 0 && s > 0 ? "\(m)m \(s)s" : (m > 0 ? "\(m) min" : "\(s) sec")
|
||||
return "\(sets) × \(hold)"
|
||||
case .none:
|
||||
return "\(sets) × \(reps) reps"
|
||||
case .weight:
|
||||
return "\(sets) × \(reps) × \(weightUnit.format(weight))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Minutes component of the total duration (for min:sec pickers).
|
||||
var durationMinutes: Int {
|
||||
get { durationTotalSeconds / 60 }
|
||||
|
||||
@@ -35,6 +35,24 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which segment of a split an exercise belongs to — the split screen groups its
|
||||
/// exercise list by this. Persisted as its raw `Int` value; `main` is the default
|
||||
/// (and what older documents without the field decode to).
|
||||
enum ExerciseCategory: Int, CaseIterable, Codable, Sendable {
|
||||
case main = 0
|
||||
case warmup = 1
|
||||
|
||||
/// Order the categories appear on screen: warm-up first, then the main circuit.
|
||||
static let displayOrder: [ExerciseCategory] = [.warmup, .main]
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .main: "Main Circuit"
|
||||
case .warmup: "Warm-up"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of training a split represents — used to tag the Apple Health workout
|
||||
/// so it lands in the right category (and credits the rings correctly), and to pick
|
||||
/// a MET value when the phone has to estimate calories without watch sensor data.
|
||||
|
||||
@@ -18,7 +18,8 @@ extension ExerciseDocument {
|
||||
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
|
||||
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
|
||||
weightLastUpdated: e.weightLastUpdated,
|
||||
weightReminderWeeks: e.weightReminderTimeIntervalWeeks)
|
||||
weightReminderWeeks: e.weightReminderTimeIntervalWeeks,
|
||||
category: e.categoryRaw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +109,8 @@ enum CacheMapper {
|
||||
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
|
||||
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
weightLastUpdated: d.weightLastUpdated,
|
||||
weightReminderTimeIntervalWeeks: d.weightReminderWeeks)
|
||||
weightReminderTimeIntervalWeeks: d.weightReminderWeeks,
|
||||
categoryRaw: d.category ?? ExerciseCategory.main.rawValue)
|
||||
}
|
||||
|
||||
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
|
||||
@@ -121,6 +123,7 @@ enum CacheMapper {
|
||||
e.durationTotalSeconds = d.durationSeconds
|
||||
e.weightLastUpdated = d.weightLastUpdated
|
||||
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
|
||||
e.categoryRaw = d.category ?? ExerciseCategory.main.rawValue
|
||||
}
|
||||
|
||||
// MARK: Workout
|
||||
|
||||
Reference in New Issue
Block a user