Add Warm-Up and Stretching activity types

Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and
.stretching (.flexibility), and retags the six starter splits that were all
mislabeled as Functional Strength:

- Warm-Up:    Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up
- Stretching: Morning Mobility, Full Body Stretch, Evening Stretch

The split editor's activity picker surfaces them automatically (CaseIterable).
Older app versions decode the new raw values as the default type — additive and
not schema-gated, so no quarantine.
This commit is contained in:
2026-07-09 15:45:31 -04:00
parent f01e149269
commit e12fe31152
308 changed files with 5594 additions and 990 deletions
@@ -12,10 +12,12 @@ import SwiftUI
/// `Exercise Library/SYSTEM.md` for the visual language).
///
/// The solver hands back a depth-sorted draw order per frame (far parts first, the head
/// always last so overhead arms pass behind the face) and a near/far tag per limb: the
/// member nearer the camera draws in the dark ink at the heavier width, the far member
/// in the light ink one step thinner and nudged by the readability offset. Working parts
/// swap the same near/far inks for the accent teals; the spine is always dark.
/// always last so overhead arms pass behind the face) and a per-vertex nearness for each
/// limb: every joint is toned by its own camera depth, so each bone is stroked with a
/// gradient between its joints' tones the ink flows along a limb reaching in depth
/// instead of snapping, and the far member sits lighter, thinner, and nudged by the
/// readability offset. Working parts swap the near/far inks for the accent teals; the
/// spine is always dark.
/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
@@ -28,9 +30,9 @@ struct FigureAnimation {
/// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
let zoom: Double
init?(exerciseName: String) {
init?(exerciseName: String, profile: FigureProfile = .neutral) {
guard
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
let resources = ExerciseMotionLibrary.resources(for: exerciseName, profile: profile),
let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
else { return nil }
self.timeline = timeline
@@ -56,6 +58,7 @@ struct FigureAnimation {
struct ExerciseFigureSlot: View {
let exerciseName: String
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
@State private var figure: FigureAnimation?
var body: some View {
@@ -67,8 +70,9 @@ struct ExerciseFigureSlot: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task(id: exerciseName) {
figure = FigureAnimation(exerciseName: exerciseName)
// Rebuild when either the exercise or the chosen body type changes.
.task(id: [exerciseName, figureProfile.rawValue]) {
figure = FigureAnimation(exerciseName: exerciseName, profile: figureProfile)
}
}
}
@@ -136,8 +140,8 @@ struct ExerciseFigureView: View {
drawSpine(&ctx, geo)
default:
guard let limb = FigureLimb(rawValue: part), let points = geo.limbs[limb] else { continue }
let shade = geo.shade[limb] ?? .near
stroke(&ctx, points, color: ink(part, shade: shade), width: shade == .near ? 6 : 5)
let tones = geo.nearness[limb] ?? Array(repeating: 1, count: points.count)
strokeLimb(&ctx, points, tones: tones, part: part)
}
}
}
@@ -154,7 +158,7 @@ struct ExerciseFigureView: View {
}
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
let color = ink("spine", shade: .near)
let color = ink("spine", nearness: 1)
stroke(&ctx, geo.girdle, color: color, width: 5)
stroke(&ctx, geo.pelvisBar, color: color, width: 5)
var path = Path()
@@ -201,13 +205,35 @@ struct ExerciseFigureView: View {
ink == .prop ? .figureProp : .figureEquipment
}
/// Near parts take the dark ink, far parts the light one; working parts swap for the
/// accent teals. The spine (passed `.near`) is always dark unless it's the working part.
private func ink(_ part: String, shade: Shade) -> Color {
if figure.working.contains(part) {
return shade == .near ? .figureNearWorking : .figureFarWorking
/// A limb's ink at `nearness` (1 = near/dark side, 0 = far/light side): the near
/// and far grays, or the accent teals for a working part. The spine (passed
/// nearness 1) is the near ink unless it's working.
private func ink(_ part: String, nearness t: Double) -> Color {
.figureShaded(working: figure.working.contains(part), nearness: t)
}
/// Strokes a limb chain with a per-vertex depth gradient: each bone segment is a
/// round-capped line filled with a linear gradient between its two joints' depth
/// tones, so the ink flows smoothly along a limb that reaches toward or away from
/// the camera instead of the bone snapping to one flat tone. Adjacent segments share
/// a joint tone, so their round caps meet seamlessly. Mirrors `render.py`.
private func strokeLimb(_ ctx: inout GraphicsContext, _ points: [CGPoint],
tones: [Double], part: String) {
guard points.count >= 2, tones.count == points.count else { return }
for i in 0..<(points.count - 1) {
let a = points[i], b = points[i + 1]
let t0 = tones[i], t1 = tones[i + 1]
let style = StrokeStyle(lineWidth: 5 + (t0 + t1) / 2, lineCap: .round)
var path = Path()
path.move(to: a)
path.addLine(to: b)
if t0 == t1 || (abs(a.x - b.x) < 0.01 && abs(a.y - b.y) < 0.01) {
ctx.stroke(path, with: .color(ink(part, nearness: t0)), style: style)
} else {
let gradient = Gradient(colors: [ink(part, nearness: t0), ink(part, nearness: t1)])
ctx.stroke(path, with: .linearGradient(gradient, startPoint: a, endPoint: b), style: style)
}
}
return shade == .near ? .figureNear : .figureFar
}
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
@@ -238,18 +264,28 @@ private extension Color {
#endif
}
/// Near-side limbs, spine, head, nose the prominent stroke (`#3a3f4b`).
static let figureNear = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
/// The gradient endpoints as `(lightMode, darkMode)` RGB pairs the near (dark,
/// prominent) and far (light, recessive) inks, plain and working. `figureShaded`
/// blends between the far and near endpoint of a pair by a limb's nearness.
private static let nearInk = (light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83)) // #3a3f4b
private static let farInk = (light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43)) // #a9afba
private static let nearWorkingInk = (light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) // #0d9488
private static let farWorkingInk = (light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52)) // #86cfc5
/// Far-side limbs, drawn behind the recessive stroke (`#a9afba`).
static let figureFar = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
static func lerp3(_ a: (Double, Double, Double), _ b: (Double, Double, Double), _ t: Double) -> (Double, Double, Double) {
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, a.2 + (b.2 - a.2) * t)
}
/// Working near-side parts teal accent (`#0d9488`).
static let figureNearWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
/// A limb's ink blended by nearness (1 = near/dark side, 0 = far/light side),
/// interpolating both the light- and dark-mode endpoints so it stays adaptive.
static func figureShaded(working: Bool, nearness t: Double) -> Color {
let far = working ? farWorkingInk : farInk
let near = working ? nearWorkingInk : nearInk
return figure(light: lerp3(far.light, near.light, t), dark: lerp3(far.dark, near.dark, t))
}
/// Working far-side parts light teal (`#86cfc5`), kept more muted than the near
/// so the near/far hierarchy holds in both modes.
static let figureFarWorking = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
/// Near-side ink for the head, nose, and non-gradient strokes (`#3a3f4b`).
static let figureNear = figure(light: nearInk.light, dark: nearInk.dark)
/// Scene equipment seats, benches, frames, cables (`#c5cad4`); sits behind
/// the figure, one step lighter than the ground line.