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,219 @@
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// An exercise flagged for deletion — captured as plain values, never a retained
|
||||
/// `@Model` (see `PendingRoutineDelete`). The routine document is rebuilt by
|
||||
/// exercise id, so the id is all the delete needs; the name is only for the prompt.
|
||||
private struct PendingExerciseDelete: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// An exercise flagged for editing — captured as just its id, not a retained `@Model`
|
||||
/// (see `PendingExerciseDelete`). The sheet re-resolves the live exercise by id off the
|
||||
/// current `routine` when it presents, so a remote delete between the tap and the
|
||||
/// sheet's next body evaluation can't hand the editor a dangling entity.
|
||||
private struct PendingExerciseEdit: Identifiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
/// The detail pane for one routine: a header (color badge, name, activity type, Edit)
|
||||
/// over a reorderable exercise list. All mutations rebuild the `RoutineDocument` and go
|
||||
/// through `syncEngine.save(routine:)` — never the SwiftData cache directly — so editing
|
||||
/// a starter forks it to a fresh ULID for free. The routine is resolved by id every
|
||||
/// render (never a captured entity) so an open detail follows that fork, mirroring iOS
|
||||
/// `RoutineDetailView`.
|
||||
struct MacRoutineDetailView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
|
||||
@State private var routineID: String
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@State private var showingRoutineEdit = false
|
||||
@State private var showingAddExercise = false
|
||||
@State private var exerciseToEdit: PendingExerciseEdit?
|
||||
@State private var pendingExerciseDelete: PendingExerciseDelete?
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(routineID: String) {
|
||||
_routineID = State(initialValue: routineID)
|
||||
}
|
||||
|
||||
/// The live routine behind the id we hold, resolved through the seed clone-on-edit
|
||||
/// redirect so a fork mid-screen keeps resolving.
|
||||
private var routine: Routine? {
|
||||
let id = syncEngine.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let routine {
|
||||
content(for: routine)
|
||||
} else {
|
||||
// The id no longer maps to a live routine (deleted elsewhere, or a
|
||||
// transient mid-clone frame).
|
||||
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingRoutineEdit) {
|
||||
if let routine { MacRoutineEditorSheet(routine: routine) }
|
||||
}
|
||||
.sheet(isPresented: $showingAddExercise) {
|
||||
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: nil)
|
||||
}
|
||||
.sheet(item: $exerciseToEdit) { pending in
|
||||
if let exercise = routine?.exercisesArray.first(where: { $0.id == pending.id }), exercise.isLive {
|
||||
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: exercise)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: Binding(
|
||||
get: { pendingExerciseDelete != nil },
|
||||
set: { if !$0 { pendingExerciseDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: pendingExerciseDelete
|
||||
) { pending in
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteExercise(id: pending.id)
|
||||
pendingExerciseDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingExerciseDelete = nil }
|
||||
} message: { pending in
|
||||
Text("Remove \"\(pending.name)\" from this routine?")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for routine: Routine) -> some View {
|
||||
List {
|
||||
Section {
|
||||
header(for: routine)
|
||||
}
|
||||
|
||||
Section("Exercises") {
|
||||
if routine.exercisesArray.isEmpty {
|
||||
Text("No exercises added yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(routine.exercisesArray) { exercise in
|
||||
ExerciseRow(exercise: exercise, weightUnit: weightUnit)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
|
||||
.contextMenu {
|
||||
Button("Edit…") { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
|
||||
Button("Duplicate") { duplicateExercise(id: exercise.id) }
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
pendingExerciseDelete = PendingExerciseDelete(id: exercise.id, name: exercise.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveExercises(routine: routine, from: source, to: destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(routine.name)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
showingAddExercise = true
|
||||
} label: {
|
||||
Label("Add Exercise", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func header(for routine: Routine) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: routine.systemImage)
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Color.color(from: routine.color))
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.color(from: routine.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(routine.name)
|
||||
.font(.title2.weight(.semibold))
|
||||
Label(routine.activityTypeEnum.displayName, systemImage: routine.activityTypeEnum.systemImage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingRoutineEdit = true
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// MARK: - Mutations (all rebuild the document, never the cache)
|
||||
|
||||
/// Reorder and renumber the exercises, then save. Resolves the routine at call time
|
||||
/// so it follows a clone-on-edit. Stamps `updatedAt` — an exercise reorder is a real
|
||||
/// content edit that should fork a starter (unlike a routine-list reorder).
|
||||
private func moveExercises(routine: Routine, from source: IndexSet, to destination: Int) {
|
||||
var ordered = routine.exercisesArray
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.exercises = ordered.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
|
||||
private func deleteExercise(id: String) {
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.exercises.removeAll { $0.id == id }
|
||||
for i in doc.exercises.indices { doc.exercises[i].order = i }
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
|
||||
/// Copy an exercise in place, right after itself (the interval-segment case). The
|
||||
/// clone is an exact duplicate under a fresh id; renaming it is a follow-up edit.
|
||||
/// Mirrors iOS `RoutineDetailView.duplicateExercise`.
|
||||
private func duplicateExercise(id: String) {
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
guard let idx = doc.exercises.firstIndex(where: { $0.id == id }) else { return }
|
||||
var copy = doc.exercises[idx]
|
||||
copy.id = ULID.make()
|
||||
doc.exercises.insert(copy, at: idx + 1)
|
||||
for i in doc.exercises.indices { doc.exercises[i].order = i }
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One exercise row: name over a one-line plan summary (`ExerciseDocument+PlanSummary`
|
||||
/// parity via `Exercise.planSummary`).
|
||||
private struct ExerciseRow: View {
|
||||
let exercise: Exercise
|
||||
let weightUnit: WeightUnit
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(exercise.name)
|
||||
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user