// // 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) } } }