diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index 91a012b..6123a77 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -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() } diff --git a/Workouts/Views/Exercises/ExerciseLibraryView.swift b/Workouts/Views/Exercises/ExerciseLibraryView.swift index ef8a775..3a3f5d2 100644 --- a/Workouts/Views/Exercises/ExerciseLibraryView.swift +++ b/Workouts/Views/Exercises/ExerciseLibraryView.swift @@ -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") } } diff --git a/Workouts/Views/Library/LibraryView.swift b/Workouts/Views/Library/LibraryView.swift new file mode 100644 index 0000000..44016c8 --- /dev/null +++ b/Workouts/Views/Library/LibraryView.swift @@ -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) + } + } + } + } +} diff --git a/Workouts/Views/Routines/RoutineListView.swift b/Workouts/Views/Routines/RoutineListView.swift deleted file mode 100644 index c4b2db9..0000000 --- a/Workouts/Views/Routines/RoutineListView.swift +++ /dev/null @@ -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) - } -} diff --git a/Workouts/Views/Routines/RoutinesLibraryView.swift b/Workouts/Views/Routines/RoutinesLibraryView.swift new file mode 100644 index 0000000..4fbc29d --- /dev/null +++ b/Workouts/Views/Routines/RoutinesLibraryView.swift @@ -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())" + } +} diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index d6cd2fa..da8f083 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -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