Files
workouts/Workouts/Views/Routines/RoutinesLibraryView.swift
T
rzen b6046bd0a9 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
2026-07-15 11:14:14 -04:00

230 lines
9.0 KiB
Swift

//
// RoutinesLibraryView.swift
// Workouts
//
// Created by rzen on 7/25/25 at 6:24 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// The Routines segment of the Library tab: a plain list of routines, one row each,
/// ordered and reorderable via `EditButton` + drag. A leading swipe duplicates a
/// routine; a trailing swipe edits or deletes. Starter routines are seeded
/// automatically on the true first run (`SyncEngine.autoSeedIfEmpty`), so an empty
/// library just invites creating one. Pushed from `LibraryView`, so it relies on that
/// view's `NavigationStack` and contributes only its own toolbar items (+ and Edit).
struct RoutinesLibraryView: View {
@Environment(SyncEngine.self) private var sync
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@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?
/// Most recent *completed* workout date per routine — "trained", not merely
/// started, mirroring `ProgressTabView`'s `completed: workout.status == .completed`.
/// `workouts` sorts start-descending, so the first completed match per live routine
/// id is its latest. Each workout's `routineID` is resolved through
/// `sync.currentRoutineID(for:)` so a workout logged against a since-edited seed
/// still credits the live clone.
private var lastTrainedByRoutineID: [String: Date] {
var map: [String: Date] = [:]
for workout in workouts where workout.status == .completed {
guard let routineID = workout.routineID else { continue }
let liveID = sync.currentRoutineID(for: routineID)
if map[liveID] == nil {
map[liveID] = workout.start
}
}
return map
}
var body: some View {
let lastTrained = lastTrainedByRoutineID
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
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 {
StarterGalleryView()
} label: {
Label("Browse Starter Routines", systemImage: "sparkles")
}
}
}
.listStyle(.insetGrouped)
.overlay {
if routines.isEmpty {
ContentUnavailableView(
"No Routines Yet",
systemImage: "dumbbell.fill",
description: Text("Create a routine to organize your workout routine.")
)
}
}
.navigationTitle("Routines")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddSheet = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add Routine")
}
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
.sheet(isPresented: $showingAddSheet) {
RoutineAddEditView(routine: nil)
}
.sheet(item: $routineToEdit) { routine in
RoutineAddEditView(routine: routine)
}
.confirmationDialog(
"Delete Routine?",
isPresented: Binding(
get: { routineToDelete != nil },
set: { if !$0 { routineToDelete = nil } }
),
titleVisibility: .visible,
presenting: routineToDelete
) { routine in
Button("Delete", role: .destructive) {
Task { await sync.delete(routine: routine) }
routineToDelete = nil
}
Button("Cancel", role: .cancel) {
routineToDelete = nil
}
} message: { routine in
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
/// a starter (its edit sheets already guard "pristine" saves against exactly that).
private func moveRoutines(from source: IndexSet, to destination: Int) {
let ids = routines.map(\.id)
let currentOrders = Dictionary(uniqueKeysWithValues: routines.map { ($0.id, $0.order) })
let changes = RoutineOrdering.changedOrders(
ids: ids, currentOrders: currentOrders, from: source, to: destination)
guard !changes.isEmpty else { return }
Task {
for change in changes {
guard let routine = routines.first(where: { $0.id == change.id }), routine.isLive else { continue }
var doc = RoutineDocument(from: routine)
doc.order = change.order
await sync.save(routine: doc)
}
}
}
}
/// A single routine row: a small rounded-square badge (the routine's color at low
/// opacity, its symbol tinted full-strength) beside the routine name — with a small
/// "Starter" capsule for a bundled seed — its exercise count, and (once it's been
/// trained at least once) how long ago.
private struct RoutineRow: View {
var routine: Routine
var lastTrained: Date?
var body: some View {
HStack(spacing: 12) {
Image(systemName: routine.systemImage)
.font(.title3)
.foregroundStyle(routineColor)
.frame(width: 36, height: 36)
.background(routineColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(routine.name)
.foregroundStyle(.primary)
if SeedLibrary.isSeed(id: routine.id) {
Text("Starter")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15), in: Capsule())
}
}
Text(caption)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var routineColor: Color {
Color.color(from: routine.color)
}
private var caption: String {
let base = "\(routine.exercisesArray.count) exercises"
guard let lastTrained else { return base }
return "\(base) · Last trained \(lastTrained.daysAgoLabel())"
}
}