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
@@ -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)
}
}
}