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