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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user