Files
workouts/Workouts/ExerciseFigure/ExerciseInfo.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

227 lines
9.1 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.
//
// ExerciseInfo.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Parsed form of an exercise's authored `info.md` (see `Exercise Library/SYSTEM.md`):
/// a one-paragraph summary, the Category/Type/Targets metadata bullets, then the
/// instructional sections (Setup, Execution, Cues, Common Mistakes, Progression…).
/// The library's markdown is uniform, so this parses the known shape rather than
/// general Markdown — SwiftUI's inline-Markdown `Text` can't render headers/lists,
/// and the app wants the structure (target chips, numbered steps) anyway.
struct ExerciseInfo {
/// The intro paragraph(s) between the `# Title` and the metadata bullets.
var summary: String
var category: String?
var type: String?
/// The comma-separated `**Targets:**` values, split for chip rendering.
var targets: [String]
var sections: [Section]
/// Parsed `**Defaults:**` bullet — the library's suggested starting plan for an
/// exercise added outside a routine (no prior log, no routine entry to copy from).
/// `nil` when the bullet is absent (not yet authored) or unparseable.
var defaults: Defaults?
/// Whether the authored `Type:` identifies this as a machine exercise — the
/// library convention is that every machine entry's type starts "Machine-based".
/// Drives the machine-settings sections' visibility before any settings exist.
var isMachineBased: Bool {
type?.localizedCaseInsensitiveContains("machine") == true
}
struct Section {
var title: String
var items: [Item]
}
/// The library's suggested starting plan, parsed from the `**Defaults:**` bullet.
struct Defaults {
var sets: Int
var reps: Int
var loadType: LoadType
var durationSeconds: Int
}
/// One block within a section, preserving the authored list flavor: numbered
/// steps render with their ordinal, bullets with a dot, paragraphs plain.
enum Item {
case step(String)
case bullet(String)
case paragraph(String)
var text: String {
switch self {
case .step(let s), .bullet(let s), .paragraph(let s): s
}
}
}
}
extension ExerciseInfo {
/// Sections read aloud in the brief hands-free cue (the workout-flow announcement).
/// The library's Speak button reads everything; a mid-workout cue skips the reference
/// sections (Common Mistakes, Progression) you don't need with the bar in your hands.
private static let briefSectionTitles: Set<String> = ["Setup", "Execution", "Cues"]
/// Render this exercise as a plain-text script for `AVSpeechSynthesizer`: the name,
/// the summary, then each section's title and items as sentences. `brief` keeps only
/// the hands-free sections (`briefSectionTitles`), falling back to every section when
/// none of them are present, so an exercise with only unusual headings still speaks.
func spokenScript(name: String, brief: Bool) -> String {
var parts: [String] = [name]
let summaryLine = summary.replacingOccurrences(of: "\n", with: " ")
if !summaryLine.trimmingCharacters(in: .whitespaces).isEmpty {
parts.append(summaryLine)
}
var chosen = brief ? sections.filter { Self.briefSectionTitles.contains($0.title) } : sections
if chosen.isEmpty { chosen = sections }
for section in chosen {
parts.append(section.title)
parts.append(contentsOf: section.items.map(\.text))
}
return parts
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { $0.hasSuffix(".") || $0.hasSuffix("!") || $0.hasSuffix("?") ? $0 : $0 + "." }
.joined(separator: " ")
}
/// Parse the library's `info.md` shape. Hard-wrapped lines are rejoined: a line
/// that doesn't start a new block (heading, list marker, blank) continues the
/// previous one, matching how Markdown renders soft breaks.
static func parse(markdown: String) -> ExerciseInfo {
var summaryLines: [String] = []
var category: String?
var type: String?
var targets: [String] = []
var defaults: Defaults?
var sections: [Section] = []
var currentSection: Section?
var currentItem: Item?
func metadataValue(_ line: String, key: String) -> String? {
let prefix = "- **\(key):**"
guard line.hasPrefix(prefix) else { return nil }
return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
}
func flushItem() {
guard let item = currentItem else { return }
currentItem = nil
if currentSection != nil {
currentSection?.items.append(item)
} else if case .paragraph(let text) = item {
summaryLines.append(text)
}
}
func flushSection() {
flushItem()
if let section = currentSection { sections.append(section) }
currentSection = nil
}
func append(to item: Item?, _ text: String) -> Item? {
switch item {
case .step(let s): .step(s + " " + text)
case .bullet(let s): .bullet(s + " " + text)
case .paragraph(let s): .paragraph(s + " " + text)
case nil: .paragraph(text)
}
}
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.isEmpty {
flushItem()
} else if line.hasPrefix("## ") {
flushSection()
currentSection = Section(title: String(line.dropFirst(3)), items: [])
} else if line.hasPrefix("# ") {
continue // the document title duplicates the exercise name
} else if currentSection == nil,
let value = metadataValue(line, key: "Category") {
flushItem()
category = value
} else if currentSection == nil,
let value = metadataValue(line, key: "Type") {
flushItem()
type = value
} else if currentSection == nil,
let value = metadataValue(line, key: "Targets") {
flushItem()
targets = value.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
} else if currentSection == nil,
let value = metadataValue(line, key: "Defaults") {
flushItem()
defaults = parseDefaults(value)
} else if let range = line.range(of: #"^\d+\.\s+"#, options: .regularExpression) {
flushItem()
currentItem = .step(String(line[range.upperBound...]))
} else if line.hasPrefix("- ") {
flushItem()
currentItem = .bullet(String(line.dropFirst(2)))
} else {
// Continuation of a hard-wrapped block (or the start of a paragraph).
currentItem = append(to: currentItem, line)
}
}
flushSection()
return ExerciseInfo(
summary: summaryLines.joined(separator: "\n\n"),
category: category,
type: type,
targets: targets,
sections: sections,
defaults: defaults
)
}
/// Parse a `**Defaults:**` value — "4 × 10 weighted", "3 × 12 bodyweight", or
/// "3 × 30 s" — into sets/reps/loadType. The trailing unit picks the `LoadType`
/// and which of reps/duration the middle number fills; anything else (missing
/// bullet, unrecognized unit) yields `nil` rather than guessing.
private static func parseDefaults(_ value: String) -> Defaults? {
let parts = value.components(separatedBy: " × ")
guard parts.count == 2, let sets = Int(parts[0]) else { return nil }
let rest = parts[1].components(separatedBy: " ")
guard rest.count == 2, let n = Int(rest[0]) else { return nil }
switch rest[1] {
case "weighted":
return Defaults(sets: sets, reps: n, loadType: .weight, durationSeconds: 0)
case "bodyweight":
return Defaults(sets: sets, reps: n, loadType: .none, durationSeconds: 0)
case "s":
return Defaults(sets: sets, reps: 0, loadType: .duration, durationSeconds: n)
default:
return nil
}
}
}
/// Finds and parses the bundled `<Exercise Name>.info.md` exported next to the
/// motion rigs by `render.py --export`.
enum ExerciseInfoLibrary {
/// The parsed info for `exerciseName`, or `nil` when none is bundled.
static func info(for exerciseName: String) -> ExerciseInfo? {
guard
let url = Bundle.main.url(forResource: exerciseName, withExtension: "info.md"),
let markdown = try? String(contentsOf: url, encoding: .utf8)
else { return nil }
return ExerciseInfo.parse(markdown: markdown)
}
}