From b6046bd0a90b88a76848118fd9a97eb87290c2e6 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 15 Jul 2026 11:14:14 -0400 Subject: [PATCH] Add a starter routine gallery with per-seed restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Views/Library/StarterGalleryView.swift | 159 ++++++++++++++++++ .../Views/Library/StarterPreviewView.swift | 142 ++++++++++++++++ .../Views/Routines/RoutinesLibraryView.swift | 78 ++++++--- Workouts/Views/Settings/SettingsView.swift | 35 ---- 4 files changed, 353 insertions(+), 61 deletions(-) create mode 100644 Workouts/Views/Library/StarterGalleryView.swift create mode 100644 Workouts/Views/Library/StarterPreviewView.swift diff --git a/Workouts/Views/Library/StarterGalleryView.swift b/Workouts/Views/Library/StarterGalleryView.swift new file mode 100644 index 0000000..4ae0a21 --- /dev/null +++ b/Workouts/Views/Library/StarterGalleryView.swift @@ -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) + } +} diff --git a/Workouts/Views/Library/StarterPreviewView.swift b/Workouts/Views/Library/StarterPreviewView.swift new file mode 100644 index 0000000..366c3b8 --- /dev/null +++ b/Workouts/Views/Library/StarterPreviewView.swift @@ -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) + } + } +} diff --git a/Workouts/Views/Routines/RoutinesLibraryView.swift b/Workouts/Views/Routines/RoutinesLibraryView.swift index 4fbc29d..0dd92fb 100644 --- a/Workouts/Views/Routines/RoutinesLibraryView.swift +++ b/Workouts/Views/Routines/RoutinesLibraryView.swift @@ -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 diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index da8f083..7580884 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -22,9 +22,6 @@ struct SettingsView: View { @AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues @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 /// `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. @@ -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 Section { switch sync.iCloudStatus {