Surface routine usage, starter-copy notices, and a grouped exercise picker
Routine detail gains a read-time Usage section (last trained, completed workout count, linked schedules) resolved through the clone redirect, and both it and the edit sheet now explain that editing a starter saves your own copy. The add-exercise picker adopts the same curated category sections and name/category/muscle search as the exercise library. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
@@ -12,6 +12,7 @@ import SwiftUI
|
||||
struct ExercisePickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedExercises: Set<String> = []
|
||||
@State private var searchText = ""
|
||||
|
||||
var onExerciseSelected: ([String]) -> Void
|
||||
var allowMultiSelect: Bool = false
|
||||
@@ -28,47 +29,34 @@ struct ExercisePickerView: View {
|
||||
self.inRoutineCounts = inRoutineCounts
|
||||
}
|
||||
|
||||
/// The same curated, searchable grouping `ExerciseLibraryView` uses, so picking an
|
||||
/// exercise reads the same way as browsing the library.
|
||||
private var sections: [ExerciseCatalogSection] {
|
||||
ExerciseCatalog.sections(matching: searchText)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(ExerciseMotionLibrary.exerciseNames, id: \.self) { exerciseName in
|
||||
if allowMultiSelect {
|
||||
Button(action: {
|
||||
if selectedExercises.contains(exerciseName) {
|
||||
selectedExercises.remove(exerciseName)
|
||||
} else {
|
||||
selectedExercises.insert(exerciseName)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text(exerciseName)
|
||||
Spacer()
|
||||
if let count = inRoutineCounts[exerciseName], count > 0 {
|
||||
Text("×\(count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if selectedExercises.contains(exerciseName) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if sections.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
Button(action: {
|
||||
onExerciseSelected([exerciseName])
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text(exerciseName)
|
||||
Spacer()
|
||||
if let count = inRoutineCounts[exerciseName], count > 0 {
|
||||
Text("×\(count)")
|
||||
.foregroundStyle(.secondary)
|
||||
List {
|
||||
ForEach(sections, id: \.title) { section in
|
||||
Section(section.title) {
|
||||
ForEach(section.names, id: \.self) { exerciseName in
|
||||
row(for: exerciseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercise Library")
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Name, category, or muscle")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
@@ -89,4 +77,44 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(for exerciseName: String) -> some View {
|
||||
if allowMultiSelect {
|
||||
Button(action: {
|
||||
if selectedExercises.contains(exerciseName) {
|
||||
selectedExercises.remove(exerciseName)
|
||||
} else {
|
||||
selectedExercises.insert(exerciseName)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text(exerciseName)
|
||||
Spacer()
|
||||
if let count = inRoutineCounts[exerciseName], count > 0 {
|
||||
Text("×\(count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if selectedExercises.contains(exerciseName) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
onExerciseSelected([exerciseName])
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text(exerciseName)
|
||||
Spacer()
|
||||
if let count = inRoutineCounts[exerciseName], count > 0 {
|
||||
Text("×\(count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,15 @@ struct RoutineAddEditView: View {
|
||||
@ViewBuilder
|
||||
private func form(for routine: Routine?) -> some View {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
Section {
|
||||
TextField("Name", text: $name)
|
||||
.bold()
|
||||
} header: {
|
||||
Text("Name")
|
||||
} footer: {
|
||||
if let routineID, SeedLibrary.isSeed(id: routineID) {
|
||||
Text("Saving changes to a starter routine creates your own copy; the starter stays available in the gallery.")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Appearance")) {
|
||||
|
||||
@@ -22,6 +22,13 @@ struct RoutineDetailView: View {
|
||||
@State private var routineID: String
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
// Read-time usage stats (below) — completed workouts and linked schedules, both
|
||||
// resolved through the seed clone-on-edit redirect so a since-edited starter still
|
||||
// credits correctly.
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
@Query private var schedules: [Schedule]
|
||||
|
||||
@State private var showingExerciseAddSheet: Bool = false
|
||||
@State private var showingRoutineEditSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@@ -66,10 +73,20 @@ struct RoutineDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for routine: Routine) -> some View {
|
||||
// Computed once per render rather than per-reference — both feed the Usage
|
||||
// section's gate and its rows below.
|
||||
let completed = completedWorkouts(for: routine)
|
||||
let linked = linkedSchedules(for: routine)
|
||||
Form {
|
||||
Section(header: Text("What is a Routine?")) {
|
||||
Section {
|
||||
Text("A \"routine\" is simply how you divide up your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
} header: {
|
||||
Text("What is a Routine?")
|
||||
} footer: {
|
||||
if SeedLibrary.isSeed(id: routine.id) {
|
||||
Text("This is a starter routine. Your edits are saved as your own copy — the original stays available in the starter gallery.")
|
||||
}
|
||||
}
|
||||
|
||||
// Headerless — what the exercise list is needs no label; the section
|
||||
@@ -122,6 +139,19 @@ struct RoutineDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !completed.isEmpty || !linked.isEmpty {
|
||||
Section("Usage") {
|
||||
if let last = completed.first {
|
||||
LabeledContent("Last Trained", value: last.start.daysAgoLabel())
|
||||
}
|
||||
LabeledContent("Workouts Completed", value: "\(completed.count)")
|
||||
ForEach(linked) { schedule in
|
||||
Label(schedule.recurrenceSummary, systemImage: "calendar")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(routine.name)
|
||||
.toolbar {
|
||||
@@ -243,4 +273,22 @@ struct RoutineDetailView: View {
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
|
||||
// MARK: - Usage
|
||||
|
||||
/// Completed workouts started from this routine, most recent first — `workouts`
|
||||
/// is start-descending, so `.first` is the latest. Matched through the seed
|
||||
/// clone-on-edit redirect on both sides, mirroring `RoutinesLibraryView`'s
|
||||
/// `lastTrainedByRoutineID`.
|
||||
private func completedWorkouts(for routine: Routine) -> [Workout] {
|
||||
workouts.filter { workout in
|
||||
guard workout.status == .completed, let routineID = workout.routineID else { return false }
|
||||
return sync.currentRoutineID(for: routineID) == routine.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules pointing at this routine, resolved through the same redirect.
|
||||
private func linkedSchedules(for routine: Routine) -> [Schedule] {
|
||||
schedules.filter { sync.currentRoutineID(for: $0.routineID) == routine.id }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user