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.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user