From 59490d319520deffda7eda61f498dac9e0d3079c Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 22 Jun 2026 22:00:30 -0400 Subject: [PATCH] Present the workout overflow actions as a bottom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the ⋯ toolbar Menu in WorkoutLogListView to a Button that presents a small detented sheet, so the Add Exercise / End Workout actions appear at the bottom within thumb reach instead of anchoring to the top under iOS 26. The tapped action is stashed and run from the sheet's onDismiss, since SwiftUI presents only one sheet at a time. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe --- .../WorkoutLogs/WorkoutLogListView.swift | 76 +++++++++++++++---- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 86dec89..2739dcb 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -27,6 +27,8 @@ struct WorkoutLogListView: View { @State private var showingAddSheet = false @State private var showingEndOptions = false + @State private var showingActionMenu = false + @State private var pendingMenuAction: MenuAction? @State private var logToDelete: WorkoutLogDocument? @State private var addedLog: LogRoute? @State private var logToEdit: LogRoute? @@ -38,6 +40,11 @@ struct WorkoutLogListView: View { /// double-fire the way a value-based `navigationDestination(for:)` would. private struct LogRoute: Identifiable, Hashable { let id: String } + /// The overflow actions surfaced by the toolbar's "…" button. The chosen action is + /// stashed here and run from the sheet's `onDismiss`, so we never try to present a + /// second sheet/dialog while the menu sheet is still on screen. + private enum MenuAction { case addExercise, endWorkout } + init(workout: Workout) { self.workout = workout _doc = State(initialValue: WorkoutDocument(from: workout)) @@ -131,24 +138,27 @@ struct WorkoutLogListView: View { } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - showingAddSheet = true - } label: { - Label("Add Exercise", systemImage: "plus") - } - if !sortedLogs.isEmpty { - Button { - showingEndOptions = true - } label: { - Label("End Workout", systemImage: "flag.checkered") - } - } + Button { + showingActionMenu = true } label: { Image(systemName: "ellipsis.circle") } + .accessibilityLabel("Workout Options") } } + .sheet(isPresented: $showingActionMenu, onDismiss: runPendingMenuAction) { + WorkoutActionsSheet( + canEndWorkout: !sortedLogs.isEmpty, + onAddExercise: { + pendingMenuAction = .addExercise + showingActionMenu = false + }, + onEndWorkout: { + pendingMenuAction = .endWorkout + showingActionMenu = false + } + ) + } .sheet(isPresented: $showingAddSheet) { SplitExercisePickerSheet( split: split, @@ -187,6 +197,18 @@ struct WorkoutLogListView: View { } } + /// Run the action chosen in the overflow sheet, once that sheet has finished + /// dismissing — presenting these follow-on sheets/dialogs while the menu is still + /// up would collide, since SwiftUI supports only one sheet at a time. + private func runPendingMenuAction() { + switch pendingMenuAction { + case .addExercise: showingAddSheet = true + case .endWorkout: showingEndOptions = true + case .none: break + } + pendingMenuAction = nil + } + /// The paged run flow, fully wired into the live channel: it broadcasts this device's /// human transitions to the watch, follows the watch's, and marks this run as open inline /// (so the propped-phone mirror cover doesn't stack on top of it). @@ -342,6 +364,34 @@ struct WorkoutLogListView: View { } } +// MARK: - Workout Actions Sheet + +/// Compact, detented bottom sheet that replaces the toolbar's overflow `Menu`. Under +/// iOS 26 a toolbar `Menu` anchors to its button at the top of the screen; presenting +/// the same actions as a small sheet keeps them at the bottom within thumb reach, and +/// lets us pin the height via `presentationDetents`. +private struct WorkoutActionsSheet: View { + let canEndWorkout: Bool + let onAddExercise: () -> Void + let onEndWorkout: () -> Void + + var body: some View { + List { + Button(action: onAddExercise) { + Label("Add Exercise", systemImage: "plus") + } + if canEndWorkout { + Button(action: onEndWorkout) { + Label("End Workout", systemImage: "flag.checkered") + } + } + } + // Sized to fit just the row(s); bump these if the row metrics change. + .presentationDetents([.height(canEndWorkout ? 200 : 140)]) + .presentationDragIndicator(.visible) + } +} + // MARK: - Split Exercise Picker Sheet struct SplitExercisePickerSheet: View {