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
This commit is contained in:
2026-07-15 10:58:57 -04:00
parent 16b61946d5
commit 586804f771
6 changed files with 285 additions and 151 deletions
+3
View File
@@ -18,6 +18,9 @@ struct ContentView: View {
Tab("Progress", systemImage: "chart.line.uptrend.xyaxis") {
ProgressTabView()
}
Tab("Library", systemImage: "books.vertical.fill") {
LibraryView()
}
Tab("Settings", systemImage: "gearshape.2") {
SettingsView()
}
@@ -8,17 +8,40 @@
import SwiftUI
import SwiftData
/// The Exercises library screen, pushed from Settings Library: every exercise with
/// a bundled motion rig (the same catalog the picker offers), each opening its
/// reference page.
/// The Exercises segment of the Library tab: every bundled exercise (the same
/// catalog the picker offers), grouped into `ExerciseCatalog`'s curated sections and
/// searchable by name, category, or target muscle. Tapping a row opens its reference
/// page. Pushed from `LibraryView`, so it relies on that view's `NavigationStack`.
struct ExerciseLibraryView: View {
@State private var searchText = ""
private var sections: [ExerciseCatalogSection] {
ExerciseCatalog.sections(matching: searchText)
}
var body: some View {
List(ExerciseMotionLibrary.exerciseNames, id: \.self) { name in
NavigationLink(name) {
ExerciseLibraryDetailView(exerciseName: name)
Group {
if sections.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List {
ForEach(sections, id: \.title) { section in
Section(section.title) {
ForEach(section.names, id: \.self) { name in
NavigationLink(name) {
ExerciseLibraryDetailView(exerciseName: name)
}
}
}
}
}
}
}
.navigationTitle("\(ExerciseMotionLibrary.exerciseNames.count) Exercises")
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Name, category, or muscle")
}
}
+48
View File
@@ -0,0 +1,48 @@
//
// LibraryView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// The Library tab's root: a segmented switch between the Routines and Exercises
/// libraries, both pushing onto one shared `NavigationStack`. Kept as a single tab
/// with a segmented control (rather than two separate tabs) so both libraries read
/// as one destination with two views, sharing a nav bar; each segment's view
/// contributes its own toolbar items (+, EditButton) and `.searchable` so those only
/// appear while that segment is showing.
struct LibraryView: View {
private enum Segment: String, CaseIterable {
case routines = "Routines"
case exercises = "Exercises"
}
@State private var segment: Segment = .routines
var body: some View {
NavigationStack {
Group {
switch segment {
case .routines:
RoutinesLibraryView()
case .exercises:
ExerciseLibraryView()
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Library Section", selection: $segment) {
ForEach(Segment.allCases, id: \.self) { segment in
Text(segment.rawValue).tag(segment)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 240)
}
}
}
}
}
@@ -1,118 +0,0 @@
//
// RoutineListView.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 library screen, pushed from Settings Library: a plain list of
/// routines, one row each. Tapping a row opens `RoutineDetailView`; long-pressing
/// offers Delete (the sole routine-delete affordance); the toolbar's + adds a new
/// routine. Starter routines are seeded automatically on the true first run
/// (`SyncEngine.autoSeedIfEmpty`), so an empty library just invites creating one.
struct RoutineListView: View {
@Environment(SyncEngine.self) private var sync
@Query(sort: \Routine.name) private var routines: [Routine]
@State private var showingAddSheet = false
@State private var routineToDelete: Routine?
var body: some View {
List {
ForEach(routines) { routine in
NavigationLink {
RoutineDetailView(routine: routine)
} label: {
RoutineRow(routine: routine)
}
.contextMenu {
Button(role: .destructive) {
routineToDelete = routine
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.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")
}
}
.sheet(isPresented: $showingAddSheet) {
RoutineAddEditView(routine: nil)
}
.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.")
}
}
}
/// 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 and its
/// exercise count.
private struct RoutineRow: View {
var routine: Routine
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) {
Text(routine.name)
.foregroundStyle(.primary)
Text("\(routine.exercisesArray.count) exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var routineColor: Color {
Color.color(from: routine.color)
}
}
@@ -0,0 +1,203 @@
//
// 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())"
}
}
+2 -27
View File
@@ -6,7 +6,6 @@
//
import SwiftUI
import SwiftData
import AVFoundation
import IndieAbout
import IndieBackup
@@ -15,8 +14,6 @@ struct SettingsView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query private var routines: [Routine]
@AppStorage("restSeconds") private var restSeconds: Int = 45
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
@@ -75,8 +72,8 @@ struct SettingsView: View {
// MARK: - Voice Section
voiceSection
// MARK: - Library Section
Section(header: Text("Library")) {
// MARK: - Goals Section
Section(header: Text("Goals")) {
NavigationLink {
GoalsListView()
} label: {
@@ -87,28 +84,6 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
}
NavigationLink {
RoutineListView()
} label: {
HStack {
Label("Routines", systemImage: "dumbbell.fill")
Spacer()
Text("\(routines.count)")
.foregroundStyle(.secondary)
}
}
NavigationLink {
ExerciseLibraryView()
} label: {
HStack {
Label("Exercises", systemImage: "figure.strengthtraining.traditional")
Spacer()
Text("\(ExerciseMotionLibrary.exerciseNames.count)")
.foregroundStyle(.secondary)
}
}
}
// MARK: - Starter Routines Section