Groundwork for the Library tab: RoutineOrdering.changedOrders turns an .onMove gesture into the minimal set of order writes (normalizing legacy colliding orders on first drag), RoutineNaming.uniqueName picks the next free "X Copy" name for duplication, and ExerciseCatalog groups the bundled exercise library into curated category sections with name/category/target search. Plus ExerciseDocument.planSummary for read-only document rendering. All covered by unit tests, including a motion/info name-parity assertion. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
85 lines
3.8 KiB
Swift
85 lines
3.8 KiB
Swift
//
|
|
// ExerciseCatalog.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// One group of exercises in the picker, in catalog display order.
|
|
struct ExerciseCatalogSection {
|
|
let title: String
|
|
let names: [String]
|
|
}
|
|
|
|
/// Curated, searchable sections over the bundled exercise library — every name from
|
|
/// `ExerciseMotionLibrary.exerciseNames`, grouped by `ExerciseInfoLibrary.info(for:)?.category`
|
|
/// in a hand-picked category order rather than the raw alphabetical/declaration order.
|
|
/// Building the grouping is a `Bundle.main` resource read per exercise, so it's done once
|
|
/// as an immutable `static let` (Swift 6 strict concurrency: no shared mutable state).
|
|
enum ExerciseCatalog {
|
|
/// Bucket for an exercise with no bundled `info.md`, or one whose category isn't in
|
|
/// `categoryOrder` — always sorts last.
|
|
static let otherTitle = "Other"
|
|
|
|
/// Curated display order for the categories actually authored in the library's
|
|
/// `info.md` files (grep `**Category:**` under `Workouts/Resources/ExerciseMotions/`):
|
|
/// "Mobility / warm-up", "Warm-up", "Warm-up and main circuit", "Main circuit",
|
|
/// "Stretching", "Cardio", "Mindfulness". Warm-up/mobility categories lead (that's
|
|
/// how a workout starts), then the main circuit, then stretching/cardio/mindfulness.
|
|
/// Any category found in the data but missing here still gets its own section
|
|
/// (appended before "Other") rather than silently absorbed into it.
|
|
private static let categoryOrder: [String] = [
|
|
"Mobility / warm-up",
|
|
"Warm-up",
|
|
"Warm-up and main circuit",
|
|
"Main circuit",
|
|
"Stretching",
|
|
"Cardio",
|
|
"Mindfulness",
|
|
]
|
|
|
|
/// Every bundled exercise, grouped into curated-order sections with alphabetized
|
|
/// names within each section.
|
|
static let sections: [ExerciseCatalogSection] = {
|
|
var namesByCategory: [String: [String]] = [:]
|
|
for name in ExerciseMotionLibrary.exerciseNames {
|
|
let category = ExerciseInfoLibrary.info(for: name)?.category ?? otherTitle
|
|
namesByCategory[category, default: []].append(name)
|
|
}
|
|
|
|
var order = categoryOrder
|
|
for category in namesByCategory.keys where category != otherTitle && !order.contains(category) {
|
|
order.append(category)
|
|
}
|
|
order.append(otherTitle)
|
|
|
|
return order.compactMap { category -> ExerciseCatalogSection? in
|
|
guard let names = namesByCategory[category], !names.isEmpty else { return nil }
|
|
return ExerciseCatalogSection(title: category, names: names.sorted())
|
|
}
|
|
}()
|
|
|
|
/// `sections` filtered to exercises matching `query` (case-insensitive) against the
|
|
/// exercise name, its category, or any of its `ExerciseInfo.targets` — preserving
|
|
/// section structure and dropping sections left with no matches. An empty/whitespace
|
|
/// query returns every section unfiltered.
|
|
static func sections(matching query: String) -> [ExerciseCatalogSection] {
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return sections }
|
|
|
|
return sections.compactMap { section -> ExerciseCatalogSection? in
|
|
let matches = section.names.filter { name in
|
|
if name.localizedCaseInsensitiveContains(trimmed) { return true }
|
|
guard let info = ExerciseInfoLibrary.info(for: name) else { return false }
|
|
if let category = info.category, category.localizedCaseInsensitiveContains(trimmed) {
|
|
return true
|
|
}
|
|
return info.targets.contains { $0.localizedCaseInsensitiveContains(trimmed) }
|
|
}
|
|
return matches.isEmpty ? nil : ExerciseCatalogSection(title: section.title, names: matches)
|
|
}
|
|
}
|
|
}
|