Add a starter routine gallery with per-seed restore

The Library tab's routines pane gains a "Browse Starter Routines" entry
opening a gallery of every bundled seed — kept or deleted — each showing
Added / name-taken / restorable state, a read-only plan preview, and a
one-tap Add that lifts the seed's delete veto. A "Re-add All Missing
Starters" fallback replaces the all-or-nothing Settings button, which is
removed. The delete confirmation now also warns how many scheduled days
would be left behind on the Today board.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-15 11:14:14 -04:00
parent 586804f771
commit b6046bd0a9
4 changed files with 353 additions and 61 deletions
@@ -25,6 +25,8 @@ struct RoutinesLibraryView: View {
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@Query private var schedules: [Schedule]
@State private var showingAddSheet = false
@State private var routineToEdit: Routine?
@State private var routineToDelete: Routine?
@@ -50,35 +52,48 @@ struct RoutinesLibraryView: View {
var body: some View {
let lastTrained = lastTrainedByRoutineID
List {
ForEach(routines) { routine in
// A separate Section so the browse-starters row never joins the
// reorderable ForEach below (EditButton's drag/delete affordances only
// ever apply to actual routines).
Section {
ForEach(routines) { routine in
NavigationLink {
RoutineDetailView(routine: routine)
} label: {
RoutineRow(routine: routine, lastTrained: lastTrained[routine.id])
}
.swipeActions(edge: .leading) {
Button {
Task { await sync.duplicate(routine: routine) }
} label: {
Label("Duplicate", systemImage: "plus.square.on.square")
}
.tint(.teal)
}
.swipeActions {
Button(role: .destructive) {
routineToDelete = routine
} label: {
Label("Delete", systemImage: "trash")
}
Button {
routineToEdit = routine
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.onMove(perform: moveRoutines)
}
Section {
NavigationLink {
RoutineDetailView(routine: routine)
StarterGalleryView()
} label: {
RoutineRow(routine: routine, lastTrained: lastTrained[routine.id])
}
.swipeActions(edge: .leading) {
Button {
Task { await sync.duplicate(routine: routine) }
} label: {
Label("Duplicate", systemImage: "plus.square.on.square")
}
.tint(.teal)
}
.swipeActions {
Button(role: .destructive) {
routineToDelete = routine
} label: {
Label("Delete", systemImage: "trash")
}
Button {
routineToEdit = routine
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
Label("Browse Starter Routines", systemImage: "sparkles")
}
}
.onMove(perform: moveRoutines)
}
.listStyle(.insetGrouped)
.overlay {
@@ -127,10 +142,21 @@ struct RoutinesLibraryView: View {
routineToDelete = nil
}
} message: { routine in
Text("This will permanently delete \"\(routine.name)\" and all its exercises. Past workouts are kept.")
Text(deleteMessage(for: routine))
}
}
/// The delete-confirmation body, extended with a note about any schedules that
/// reference this routine deleting the routine leaves them behind (schedules
/// aren't cascaded), so the Today board would otherwise show an orphaned day with
/// no explanation.
private func deleteMessage(for routine: Routine) -> String {
let base = "This will permanently delete \"\(routine.name)\" and all its exercises. Past workouts are kept."
let count = schedules.filter { sync.currentRoutineID(for: $0.routineID) == routine.id }.count
guard count > 0 else { return base }
return base + " Its \(count) scheduled day\(count == 1 ? "" : "s") will stay on the Today board without a startable routine."
}
/// Reorder and renumber via `RoutineOrdering`, writing only the routines whose
/// order actually changed. Order-only writes deliberately leave `updatedAt`
/// alone it's bookkeeping, not content, and touching it needlessly risks forking