Files
workouts/Workouts Mac/Views/Exercises/MacExercisesBrowserView.swift
rzen cbdf02bca7 Add the macOS app: a library-management companion (routines, schedules, exercise browser)
A new 'Workouts Mac' target (same bundle ID as iOS, universal purchase)
with a NavigationSplitView shell over the shared data/sync layers:
routine management with starter gallery and seed-fork follow, schedule
editing (reminders stay iPhone-scheduled), and a browse-only exercise
library with the animated figures and reference guides.

Multi-writer stance: the Mac writes only routine and schedule documents
(whole-document last-writer-wins) and never workout documents, whose
per-log merge exists only on the watch ingest path. The Mac reconciles
on window activation (throttled) since iCloud syncs while the app is
closed, and flushes pending writes on deactivation.
2026-07-16 19:55:06 -04:00

124 lines
4.6 KiB
Swift

import SwiftUI
/// The bundled exercise-library browser: a Mac-native two-pane layout over the same
/// `ExerciseCatalog` sections and search the iOS Library tab uses
/// (`Workouts/Views/Exercises/ExerciseLibraryView.swift`), but browse-only — no
/// machine-settings editor, no weight-progression chart, no workout history. The left
/// pane is a searchable, section-grouped list with a single selection; the right pane
/// shows the selected exercise's animated figure and reference info, the same
/// components/entry points iOS uses (`ExerciseFigureSlot`, `ExerciseInfoContent`).
struct MacExercisesBrowserView: View {
@State private var searchText = ""
@State private var selection: String?
private var sections: [ExerciseCatalogSection] {
ExerciseCatalog.sections(matching: searchText)
}
var body: some View {
HSplitView {
list
.frame(minWidth: 220, idealWidth: 260, maxWidth: 340)
detail
.frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Exercises")
.searchable(text: $searchText, prompt: "Name, category, or muscle")
}
// MARK: - List
@ViewBuilder
private var list: some View {
if sections.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(selection: $selection) {
ForEach(sections, id: \.title) { section in
Section(section.title) {
ForEach(section.names, id: \.self) { name in
Text(name)
.tag(name)
}
}
}
}
}
}
// MARK: - Detail
@ViewBuilder
private var detail: some View {
if let selection {
MacExerciseDetailView(exerciseName: selection)
// Reseed the detail's own state when the selection changes.
.id(selection)
} else {
ContentUnavailableView(
"Select an Exercise",
systemImage: "figure.strengthtraining.traditional",
description: Text("Choose an exercise from the list to see how it's performed.")
)
}
}
}
/// One library exercise, browse-only: the animated form-guide figure (when a bundled
/// motion rig matches the name) over the authored reference copy (when a bundled
/// `info.md` matches). Either — or both — can be absent for a given exercise, so each
/// half degrades gracefully instead of leaving blank space, mirroring how the iOS
/// detail screen simply omits what it doesn't have (`ExerciseLibraryDetailView`).
private struct MacExerciseDetailView: View {
let exerciseName: String
/// Cheap presence check — a name-set lookup against the bundle enumeration
/// `ExerciseMotionLibrary.exerciseNames` already did once, rather than
/// `resources(for:)`'s two bundle reads + two JSON decodes just to answer a Bool.
private var hasFigure: Bool {
ExerciseMotionLibrary.exerciseNames.contains(exerciseName)
}
var body: some View {
let info = ExerciseInfoLibrary.info(for: exerciseName)
let hasFigure = hasFigure
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if hasFigure || info == nil {
figureArea(hasFigure: hasFigure)
}
if let info {
ExerciseInfoContent(info: info)
} else if hasFigure {
Text("No reference guide written for this exercise yet.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle(exerciseName)
}
/// The figure, sized generously for the Mac detail pane, or — when neither a
/// motion rig nor reference info exists for this name — a single combined empty
/// state rather than two stacked placeholders.
@ViewBuilder
private func figureArea(hasFigure: Bool) -> some View {
if hasFigure {
ExerciseFigureSlot(exerciseName: exerciseName)
.frame(maxWidth: .infinity)
.frame(height: 260)
} else {
ContentUnavailableView(
exerciseName,
systemImage: "figure.strengthtraining.traditional",
description: Text("No animated guide or reference info is available for this exercise yet.")
)
.frame(maxWidth: .infinity)
}
}
}