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:
@@ -0,0 +1,159 @@
|
|||||||
|
//
|
||||||
|
// StarterGalleryView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Where a bundled seed currently stands relative to the live cache — computed fresh
|
||||||
|
/// from `@Query` routines on every render so a local edit, a remote change, or a
|
||||||
|
/// fork-on-edit clone is reflected immediately. Shared by the gallery list and the
|
||||||
|
/// preview screen so both classify a seed identically.
|
||||||
|
enum StarterSeedState {
|
||||||
|
/// A live routine with the seed's fixed id exists — the starter is already in the
|
||||||
|
/// user's library (possibly since edited elsewhere; the id itself only forks at
|
||||||
|
/// edit time, so "added" still matches a pristine seed).
|
||||||
|
case added(Routine)
|
||||||
|
/// Not added, but some *other* live routine already carries the seed's name —
|
||||||
|
/// restoring would read as a confusing duplicate, so the add action is blocked.
|
||||||
|
case nameTaken
|
||||||
|
/// Neither added nor blocked — free to restore.
|
||||||
|
case restorable
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SeedLibrary.Seed {
|
||||||
|
/// Classify this seed against the live routine set. See `StarterSeedState`.
|
||||||
|
func state(among routines: [Routine]) -> StarterSeedState {
|
||||||
|
if let live = routines.first(where: { $0.id == id }) { return .added(live) }
|
||||||
|
if routines.contains(where: { $0.name == doc.name }) { return .nameTaken }
|
||||||
|
return .restorable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bundled starter routine — including ones the user has deleted — with a
|
||||||
|
/// per-seed Add/Restore action and a bulk "re-add everything missing" fallback. Unlike
|
||||||
|
/// `RoutinesLibraryView` (which only ever shows *live* routines), this reads straight
|
||||||
|
/// from `SeedLibrary.seeds`, the bundle-derived source of truth, so a deleted starter
|
||||||
|
/// still has a row here. Pushed from that view's "Browse Starter Routines" entry.
|
||||||
|
struct StarterGalleryView: View {
|
||||||
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
|
||||||
|
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||||
|
private var routines: [Routine]
|
||||||
|
|
||||||
|
@State private var isRestoringAll = false
|
||||||
|
@State private var restoreAllMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(SeedLibrary.seeds, id: \.id) { seed in
|
||||||
|
row(for: seed)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Every routine Workouts ships with, whether or not you've kept it. Adding one never touches a routine you've created or edited.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isRestoringAll = true
|
||||||
|
restoreAllMessage = nil
|
||||||
|
let n = await sync.restoreSeeds()
|
||||||
|
isRestoringAll = false
|
||||||
|
restoreAllMessage = n == 0
|
||||||
|
? "All starter routines are already present."
|
||||||
|
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label("Re-add All Missing Starters", systemImage: "arrow.counterclockwise")
|
||||||
|
if isRestoringAll {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRestoringAll || sync.iCloudStatus != .available)
|
||||||
|
} footer: {
|
||||||
|
if let restoreAllMessage {
|
||||||
|
Text(restoreAllMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle("Starter Routines")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Row
|
||||||
|
|
||||||
|
/// One seed's row: always navigable (to the live detail once added, otherwise the
|
||||||
|
/// read-only preview), with a state-appropriate trailing accessory — a checkmark
|
||||||
|
/// once added, an inline Add button while restorable (a `.borderless` button style
|
||||||
|
/// keeps its tap from also firing the row's `NavigationLink`), or nothing extra
|
||||||
|
/// when blocked (the preview screen explains why).
|
||||||
|
@ViewBuilder
|
||||||
|
private func row(for seed: SeedLibrary.Seed) -> some View {
|
||||||
|
switch seed.state(among: routines) {
|
||||||
|
case .added(let routine):
|
||||||
|
NavigationLink {
|
||||||
|
RoutineDetailView(routine: routine)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
content(for: seed.doc)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .nameTaken:
|
||||||
|
NavigationLink {
|
||||||
|
StarterPreviewView(seed: seed)
|
||||||
|
} label: {
|
||||||
|
content(for: seed.doc, caption: "A routine named \"\(seed.doc.name)\" already exists")
|
||||||
|
}
|
||||||
|
case .restorable:
|
||||||
|
NavigationLink {
|
||||||
|
StarterPreviewView(seed: seed)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
content(for: seed.doc)
|
||||||
|
Spacer()
|
||||||
|
Button("Add") {
|
||||||
|
Task { _ = await sync.restoreSeed(id: seed.id) }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(sync.iCloudStatus != .available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row's icon badge + name + caption — the same visual language as
|
||||||
|
/// `RoutinesLibraryView`'s `RoutineRow`, but built from the seed's `RoutineDocument`
|
||||||
|
/// rather than a live `Routine` (a not-yet-added seed has no entity to read).
|
||||||
|
/// `caption` overrides the default exercise count when the row needs to explain
|
||||||
|
/// itself instead.
|
||||||
|
private func content(for doc: RoutineDocument, caption: String? = nil) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: doc.systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.color(from: doc.color))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Color.color(from: doc.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(doc.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(caption ?? "\(doc.exercises.count) exercises")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//
|
||||||
|
// StarterPreviewView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Read-only preview of one bundled starter routine — its icon, activity type, and
|
||||||
|
/// full exercise plan, exactly as it would land in the library — with the action that
|
||||||
|
/// actually installs it. Pushed from `StarterGalleryView` for a seed that isn't
|
||||||
|
/// already a live routine (or, read-only, for one blocked by a name collision).
|
||||||
|
struct StarterPreviewView: View {
|
||||||
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||||
|
private var routines: [Routine]
|
||||||
|
|
||||||
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||||
|
|
||||||
|
let seed: SeedLibrary.Seed
|
||||||
|
|
||||||
|
@State private var isAdding = false
|
||||||
|
|
||||||
|
private var state: StarterSeedState { seed.state(among: routines) }
|
||||||
|
|
||||||
|
private var activityType: WorkoutActivityType {
|
||||||
|
seed.doc.activityType.flatMap(WorkoutActivityType.init(rawValue:)) ?? .traditionalStrength
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
exerciseList
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) { actionBar }
|
||||||
|
.navigationTitle(seed.doc.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: seed.doc.systemImage)
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(Color.color(from: seed.doc.color))
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(Color.color(from: seed.doc.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(seed.doc.name)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
Label(activityType.displayName, systemImage: activityType.systemImage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exercises
|
||||||
|
|
||||||
|
private var exerciseList: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Exercises")
|
||||||
|
.font(.headline)
|
||||||
|
ForEach(seed.doc.exercises.sorted(by: { $0.order < $1.order })) { exercise in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(exercise.name)
|
||||||
|
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Action
|
||||||
|
|
||||||
|
/// The prominent bottom action, reflecting the same three states the gallery row
|
||||||
|
/// does: already added (a link to the live routine), blocked by a name collision
|
||||||
|
/// (disabled, with the reason), or free to restore (active).
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionBar: some View {
|
||||||
|
switch state {
|
||||||
|
case .added(let routine):
|
||||||
|
NavigationLink {
|
||||||
|
RoutineDetailView(routine: routine)
|
||||||
|
} label: {
|
||||||
|
Label("Added — View Routine", systemImage: "checkmark.circle.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.green)
|
||||||
|
.padding()
|
||||||
|
.background(.thinMaterial)
|
||||||
|
case .nameTaken:
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("Add to My Routines")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.secondary.opacity(0.15), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("A routine named \"\(seed.doc.name)\" already exists.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.thinMaterial)
|
||||||
|
case .restorable:
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isAdding = true
|
||||||
|
let added = await sync.restoreSeed(id: seed.id)
|
||||||
|
isAdding = false
|
||||||
|
// The @Query reflects the new routine the instant the cache mirror
|
||||||
|
// lands, so popping back to the gallery (which now shows this seed
|
||||||
|
// as Added) is enough — no local flag needed to fake that state here.
|
||||||
|
if added { dismiss() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if isAdding { ProgressView().tint(.white) }
|
||||||
|
Text(isAdding ? "Adding…" : "Add to My Routines")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isAdding || sync.iCloudStatus != .available)
|
||||||
|
.padding()
|
||||||
|
.background(.thinMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ struct RoutinesLibraryView: View {
|
|||||||
@Query(sort: \Workout.start, order: .reverse)
|
@Query(sort: \Workout.start, order: .reverse)
|
||||||
private var workouts: [Workout]
|
private var workouts: [Workout]
|
||||||
|
|
||||||
|
@Query private var schedules: [Schedule]
|
||||||
|
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
@State private var routineToEdit: Routine?
|
@State private var routineToEdit: Routine?
|
||||||
@State private var routineToDelete: Routine?
|
@State private var routineToDelete: Routine?
|
||||||
@@ -50,6 +52,10 @@ struct RoutinesLibraryView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let lastTrained = lastTrainedByRoutineID
|
let lastTrained = lastTrainedByRoutineID
|
||||||
List {
|
List {
|
||||||
|
// 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
|
ForEach(routines) { routine in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
RoutineDetailView(routine: routine)
|
RoutineDetailView(routine: routine)
|
||||||
@@ -80,6 +86,15 @@ struct RoutinesLibraryView: View {
|
|||||||
}
|
}
|
||||||
.onMove(perform: moveRoutines)
|
.onMove(perform: moveRoutines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
StarterGalleryView()
|
||||||
|
} label: {
|
||||||
|
Label("Browse Starter Routines", systemImage: "sparkles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.overlay {
|
.overlay {
|
||||||
if routines.isEmpty {
|
if routines.isEmpty {
|
||||||
@@ -127,10 +142,21 @@ struct RoutinesLibraryView: View {
|
|||||||
routineToDelete = nil
|
routineToDelete = nil
|
||||||
}
|
}
|
||||||
} message: { routine in
|
} 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
|
/// Reorder and renumber via `RoutineOrdering`, writing only the routines whose
|
||||||
/// order actually changed. Order-only writes deliberately leave `updatedAt`
|
/// order actually changed. Order-only writes deliberately leave `updatedAt`
|
||||||
/// alone — it's bookkeeping, not content, and touching it needlessly risks forking
|
/// alone — it's bookkeeping, not content, and touching it needlessly risks forking
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ struct SettingsView: View {
|
|||||||
@AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues
|
@AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues
|
||||||
@AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = ""
|
@AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = ""
|
||||||
|
|
||||||
@State private var isRestoringSeeds = false
|
|
||||||
@State private var restoreSeedsMessage: String?
|
|
||||||
|
|
||||||
/// Installed English voices, best quality first. Loaded on appear (not per render) since
|
/// Installed English voices, best quality first. Loaded on appear (not per render) since
|
||||||
/// `speechVoices()` is a touch heavy and the list only changes when the user downloads a
|
/// `speechVoices()` is a touch heavy and the list only changes when the user downloads a
|
||||||
/// voice in system settings — re-read on each appear catches a return from doing exactly that.
|
/// voice in system settings — re-read on each appear catches a return from doing exactly that.
|
||||||
@@ -86,38 +83,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Starter Routines Section
|
|
||||||
Section {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
isRestoringSeeds = true
|
|
||||||
restoreSeedsMessage = nil
|
|
||||||
let n = await sync.restoreSeeds()
|
|
||||||
isRestoringSeeds = false
|
|
||||||
restoreSeedsMessage = n == 0
|
|
||||||
? "All starter routines are already present."
|
|
||||||
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Label("Restore Starter Routines", systemImage: "arrow.counterclockwise")
|
|
||||||
if isRestoringSeeds {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isRestoringSeeds || sync.iCloudStatus != .available)
|
|
||||||
} header: {
|
|
||||||
Text("Starter Routines")
|
|
||||||
} footer: {
|
|
||||||
if let restoreSeedsMessage {
|
|
||||||
Text(restoreSeedsMessage)
|
|
||||||
} else {
|
|
||||||
Text("Brings back the bundled starter routines you've deleted. Routines you've edited or created are never touched.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - iCloud Sync Section
|
// MARK: - iCloud Sync Section
|
||||||
Section {
|
Section {
|
||||||
switch sync.iCloudStatus {
|
switch sync.iCloudStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user