Add pure helpers for routine ordering, copy naming, and a grouped exercise catalog
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
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// ExerciseDocument+PlanSummary.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ExerciseDocument {
|
||||
/// One-line plan summary for list rows — mirrors `Exercise.planSummary(weightUnit:)`
|
||||
/// (`Shared/Model/Entities.swift`) exactly, but works from the on-disk document
|
||||
/// rather than the SwiftData cache entity: duration exercises show their hold time
|
||||
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones keep
|
||||
/// the full "3 × 10 × 40 lb".
|
||||
func planSummary(weightUnit: WeightUnit) -> String {
|
||||
switch LoadType(rawValue: loadType) ?? .weight {
|
||||
case .duration:
|
||||
let m = durationSeconds / 60, s = durationSeconds % 60
|
||||
let hold = m > 0 && s > 0 ? "\(m)m \(s)s" : (m > 0 ? "\(m) min" : "\(s) sec")
|
||||
return "\(sets) × \(hold)"
|
||||
case .none:
|
||||
return "\(sets) × \(reps) reps"
|
||||
case .weight:
|
||||
return "\(sets) × \(reps) × \(weightUnit.format(weight))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// RoutineOrdering.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Pure reordering math shared by any list backed by `.onMove` + a `SyncEngine`
|
||||
/// document write: given the row's current ids and a SwiftUI move gesture, compute
|
||||
/// the renumbered `order` values and hand back only the ones that actually changed,
|
||||
/// so a caller writes the smallest possible document diff.
|
||||
enum RoutineOrdering {
|
||||
/// Apply SwiftUI's `.onMove` semantics to `ids` (`Array.move(fromOffsets:toOffset:)`),
|
||||
/// renumber the result 0…n-1, and return only the `(id, newOrder)` pairs whose order
|
||||
/// differs from `currentOrders[id]`. An id absent from `currentOrders` always comes
|
||||
/// back (there's nothing to compare against, so it can't be assumed unchanged).
|
||||
///
|
||||
/// Comparing every renumbered position against the stored value — rather than only
|
||||
/// the ones the drag actually touched — also normalizes legacy colliding order
|
||||
/// values (e.g. two exercises both stored at order 0) the first time the list is
|
||||
/// dragged: every row gets its correct 0…n-1 position written, not just the moved one.
|
||||
static func changedOrders(
|
||||
ids: [String], currentOrders: [String: Int], from source: IndexSet, to destination: Int
|
||||
) -> [(id: String, order: Int)] {
|
||||
var reordered = ids
|
||||
reordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
return reordered.enumerated().compactMap { index, id in
|
||||
currentOrders[id] == index ? nil : (id: id, order: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Naming helper for the "duplicate this routine" action.
|
||||
enum RoutineNaming {
|
||||
/// The next free "«base» Copy" name: `"«base» Copy"` when that's unused, else
|
||||
/// `"«base» Copy 2"`, `"«base» Copy 3"`, … — skipping any name already present in
|
||||
/// `existing` (case-sensitive match).
|
||||
static func uniqueName(base: String, existing: Set<String>) -> String {
|
||||
let first = "\(base) Copy"
|
||||
guard existing.contains(first) else { return first }
|
||||
|
||||
var suffix = 2
|
||||
while existing.contains("\(base) Copy \(suffix)") {
|
||||
suffix += 1
|
||||
}
|
||||
return "\(base) Copy \(suffix)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user