Library detail screens now lead with the user's recorded machine settings (per-split when they disagree, empty-state card for machine-based entries) and append the weight progression chart. Starter seeds mark machine exercises with an empty machineSettings list so the settings UI lights up before first use. The figure rig gains a frontal body profile for face-on machines, props that can ride mid joints (knees/elbows), and an alternating four-frame Bird Dog loop. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
156 lines
5.8 KiB
Swift
156 lines
5.8 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]
|
|
|
|
/// 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]
|
|
}
|
|
|
|
/// 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 {
|
|
/// 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 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 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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|