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
227 lines
9.1 KiB
Swift
227 lines
9.1 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|