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
230 lines
9.0 KiB
Swift
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())"
|
|
}
|
|
}
|