Files
workouts/Workouts/Views/Routines/RoutinesLibraryView.swift
T
rzen 586804f771 Add the Library tab: reorderable routines pane and searchable grouped exercises pane
A fourth tab promotes routine and exercise management out of Settings.
LibraryView switches between two segments on one navigation stack. The
routines pane (RoutineListView reborn as RoutinesLibraryView) sorts by
the user's order, gains drag-to-reorder writing only changed orders,
swipe actions for duplicate/edit/delete, a Starter badge on bundled
seeds, and a last-trained caption. The exercises pane groups the library
into curated category sections searchable by name, category, or muscle.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 10:58:57 -04:00

204 lines
7.7 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]
@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 {
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)
}
.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("This will permanently delete \"\(routine.name)\" and all its exercises. Past workouts are kept.")
}
}
/// 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())"
}
}