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