Open new workouts directly and improve accessibility
Starting a workout — from the split picker or a split's exercise list — now drops straight into its log screen once the cache catches up, via a shared StartedWorkoutNavigator. Adds VoiceOver labels/values to the log checkboxes and the settings button, a color-independent numbered legend and spoken summary to the heart-rate-zone bar, and Dynamic Type scaling to the run-flow badges and timer. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -21,6 +21,10 @@ struct WorkoutLogsView: View {
|
||||
@State private var showingSplitPicker = false
|
||||
@State private var showingSettings = false
|
||||
@State private var itemToDelete: Workout?
|
||||
/// ID of the just-started workout; drives the push into its log screen once the
|
||||
/// cache observer delivers the entity a beat after the file write (see
|
||||
/// `navigatesToStartedWorkout`).
|
||||
@State private var pendingWorkoutID: String?
|
||||
|
||||
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
|
||||
var body: some View {
|
||||
@@ -64,6 +68,7 @@ struct WorkoutLogsView: View {
|
||||
} label: {
|
||||
Image(systemName: "gearshape.2")
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Start New") {
|
||||
@@ -75,8 +80,11 @@ struct WorkoutLogsView: View {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $showingSplitPicker) {
|
||||
SplitPickerSheet()
|
||||
SplitPickerSheet { pendingWorkoutID = $0 }
|
||||
}
|
||||
// Once "Start New" saves a workout, drop straight into its log screen —
|
||||
// the same landing the split's exercise-list start path uses.
|
||||
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||
.confirmationDialog(
|
||||
"Delete Workout?",
|
||||
isPresented: Binding(
|
||||
@@ -126,6 +134,10 @@ struct SplitPickerSheet: View {
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Called with the new workout's id right after it's saved, so the presenting
|
||||
/// screen can navigate into it once the cache catches up.
|
||||
var onStarted: (String) -> Void = { _ in }
|
||||
|
||||
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
||||
private var splits: [Split]
|
||||
|
||||
@@ -237,10 +249,64 @@ struct SplitPickerSheet: View {
|
||||
logs: logs
|
||||
)
|
||||
|
||||
Task { await sync.save(workout: doc) }
|
||||
// Hand the id back after the save so the presenter can poll the cache and
|
||||
// navigate into the run — mirroring the exercise-list start path.
|
||||
Task {
|
||||
await sync.save(workout: doc)
|
||||
onStarted(doc.id)
|
||||
}
|
||||
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
||||
// tagged with the split's activity type so the saved Health workout is categorized.
|
||||
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Started-Workout Navigation
|
||||
|
||||
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
|
||||
/// paths — the split picker sheet and a split's exercise list — mint a `WorkoutDocument`,
|
||||
/// write it, then hand its id here; the workout row only materializes once the
|
||||
/// file→observer→cache loop round-trips, so we poll the cache for the entity and push it
|
||||
/// the moment it lands.
|
||||
private struct StartedWorkoutNavigator: ViewModifier {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Binding var pendingWorkoutID: String?
|
||||
@State private var resolvedWorkout: Workout?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationDestination(item: $resolvedWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll for the entity after we write the document (the file→observer→cache loop
|
||||
/// typically completes in well under a second). Clear the pending id once it
|
||||
/// resolves, or silently after ~3 s if it never arrives.
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
|
||||
resolvedWorkout = workout
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Navigate into a workout's log screen once it appears in the cache after being
|
||||
/// started. Bind to the state you set to the new workout's id right after saving it.
|
||||
func navigatesToStartedWorkout(pendingWorkoutID: Binding<String?>) -> some View {
|
||||
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user