diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift index fac4b6d..61e4c93 100644 --- a/Workouts/ExerciseFigure/ExerciseFigureView.swift +++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift @@ -89,8 +89,9 @@ struct ExerciseFigureView: View { draw(&ctx, size: size, time: context.date.timeIntervalSinceReferenceDate) } } - .accessibilityLabel("Animated form guide") - .accessibilityHidden(false) + // Purely decorative: the paged flow already conveys the exercise and its + // progress, so the looping figure is skipped by VoiceOver. + .accessibilityHidden(true) } private func draw(_ ctx: inout GraphicsContext, size: CGSize, time: Double) { diff --git a/Workouts/Views/Common/CalendarListItem.swift b/Workouts/Views/Common/CalendarListItem.swift index 969270c..f2ba3d2 100644 --- a/Workouts/Views/Common/CalendarListItem.swift +++ b/Workouts/Views/Common/CalendarListItem.swift @@ -32,6 +32,9 @@ struct CalendarListItem: View { } .padding([.trailing], 10) } + // Read the date as one phrase instead of three disjoint "Mon" / "14" / "Jul" stops. + .accessibilityElement(children: .ignore) + .accessibilityLabel(date.formatted(date: .complete, time: .omitted)) HStack(alignment: .top) { VStack(alignment: .leading) { Text(title) diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index 74b2833..c58ec7a 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -13,7 +13,6 @@ import SwiftData struct ExerciseListView: View { @Environment(SyncEngine.self) private var sync - @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss // Resolve the split by id, not a captured entity: editing a seed's exercise from @@ -26,9 +25,9 @@ struct ExerciseListView: View { @State private var itemToEdit: Exercise? = nil @State private var itemToDelete: Exercise? = nil /// ID of the just-created workout; drives programmatic navigation once the - /// cache observer delivers the entity a beat after the file write. + /// cache observer delivers the entity a beat after the file write (see + /// `navigatesToStartedWorkout`). @State private var pendingWorkoutID: String? = nil - @State private var resolvedWorkout: Workout? = nil @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] @@ -58,15 +57,8 @@ struct ExerciseListView: View { .task { dismiss() } } } - // Navigate to the workout log once the entity appears in the cache. - .navigationDestination(item: $resolvedWorkout) { workout in - WorkoutLogListView(workout: workout) - } - // Poll for the entity after we write the document. - .onChange(of: pendingWorkoutID) { _, id in - guard let id else { return } - pollForWorkout(id: id) - } + // Navigate into the workout's log screen once the entity appears in the cache. + .navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID) } @ViewBuilder @@ -192,22 +184,6 @@ struct ExerciseListView: View { start() } - private func pollForWorkout(id: String) { - Task { - // Give the file→observer→cache loop a moment to complete (typically < 1 s). - 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 - } - } - // If still not available after ~3 s, clear the pending ID silently. - pendingWorkoutID = nil - } - } - private func moveExercises(from source: IndexSet, to destination: Int) { guard let split else { return } var exercises = split.exercisesArray diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 73ad2db..357c53d 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -621,6 +621,10 @@ private struct PhaseTimerLayout: View { let tint: Color @ViewBuilder var timer: Content + /// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor` + /// so the largest sizes never overflow the top half). + @ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108 + var body: some View { VStack(spacing: 10) { Text(header) @@ -628,7 +632,7 @@ private struct PhaseTimerLayout: View { .foregroundStyle(.secondary) timer - .font(.system(size: 108, weight: .bold, design: .rounded)) + .font(.system(size: timerFontSize, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(tint) .lineLimit(1) @@ -715,6 +719,9 @@ private extension View { private struct CompletedPhaseView: View { let log: WorkoutLogDocument? + /// Scales the completion badge with Dynamic Type. + @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96 + /// "4 sets × 12 reps" / "3 sets × 45 sec". private var planSummary: String { guard let log else { return "" } @@ -736,7 +743,7 @@ private struct CompletedPhaseView: View { var body: some View { VStack(spacing: 14) { Image(systemName: "checkmark.circle.fill") - .font(.system(size: 96, weight: .bold)) + .font(.system(size: badgeSize, weight: .bold)) .foregroundStyle(Color.accentColor) Text("Completed") .font(.system(.title, design: .rounded, weight: .heavy)) @@ -766,10 +773,13 @@ private struct CompletedPhaseView: View { /// ended early with this one unfinished). Same badge treatment as Completed, /// in gray, matching the list row's skipped icon. private struct SkippedPhaseView: View { + /// Scales the skipped badge with Dynamic Type. + @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96 + var body: some View { VStack(spacing: 14) { Image(systemName: "wrongwaysign") - .font(.system(size: 96, weight: .bold)) + .font(.system(size: badgeSize, weight: .bold)) .foregroundStyle(.gray) Text("Skipped") .font(.system(.title, design: .rounded, weight: .heavy)) @@ -785,10 +795,15 @@ private struct ReadyPhaseView: View { let summary: String let onStart: () -> Void + /// Scales the "Ready?" headline with Dynamic Type. + @ScaledMetric(relativeTo: .largeTitle) private var readyFontSize: CGFloat = 44 + var body: some View { VStack(spacing: 14) { Text("Ready?") - .font(.system(size: 44, weight: .bold, design: .rounded)) + .font(.system(size: readyFontSize, weight: .bold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.5) if !summary.isEmpty { Text(summary) diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 91358f4..bb6dce8 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -443,6 +443,19 @@ private struct WorkoutLogRow: View { private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none } + /// Spoken status for the checkbox: the same states the icon encodes, plus the + /// set progress while an exercise is mid-flight (which the icon can't show). + private var statusAccessibilityValue: String { + switch WorkoutStatus(rawValue: log.status) ?? .notStarted { + case .notStarted: return "Not started" + case .inProgress: + let done = min(max(0, log.currentStateIndex), log.sets) + return "In progress, \(done) of \(log.sets) sets" + case .completed: return "Completed" + case .skipped: return "Skipped" + } + } + /// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full /// strength and the × dimmed, so the counts read at a glance. private var setsAndReps: Text { @@ -497,6 +510,9 @@ private struct WorkoutLogRow: View { .foregroundStyle(status.color) } .buttonStyle(.plain) + .accessibilityLabel(log.exerciseName) + .accessibilityValue(statusAccessibilityValue) + .accessibilityHint("Advances this exercise's completion status") VStack(alignment: .leading, spacing: 4) { Text(log.exerciseName) diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index 48025e4..5a19951 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -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) -> some View { + modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID)) + } +} diff --git a/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift b/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift index 7f1ad41..49d8602 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift @@ -150,31 +150,69 @@ struct MetricTile: View { } } -/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (low→high). +/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (low→high), +/// with a numbered legend so the zones read without relying on color, and a single +/// spoken summary of time per zone for VoiceOver. struct HRZoneBar: View { let zoneSeconds: [Double] private let colors: [Color] = [.blue, .green, .yellow, .orange, .red] + /// Indices of the zones that actually accrued time — the only ones drawn and listed. + private var activeZones: [Int] { + (0.. 0 } + } + var body: some View { let total = max(zoneSeconds.reduce(0, +), 1) - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text("Heart Rate Zones") .font(.caption) .foregroundStyle(.secondary) GeometryReader { geo in HStack(spacing: 2) { - ForEach(0..<5, id: \.self) { zone in - if zoneSeconds[zone] > 0 { - colors[zone] - .frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total))) - } + ForEach(activeZones, id: \.self) { zone in + colors[zone] + .frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total))) } } } .frame(height: 12) .clipShape(Capsule()) + + // Numbered legend — each active zone's swatch beside its number, so the + // split reads for color-blind (and all) sighted users, not by hue alone. + HStack(spacing: 12) { + ForEach(activeZones, id: \.self) { zone in + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 3) + .fill(colors[zone]) + .frame(width: 10, height: 10) + Text("Zone \(zone + 1)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } } .frame(maxWidth: .infinity, alignment: .leading) + // Collapse the whole bar+legend into one element with a spoken per-zone breakdown. + .accessibilityElement(children: .ignore) + .accessibilityLabel("Heart rate zones") + .accessibilityValue(spokenSummary) + } + + /// "Zone 1, 2 min 30 sec. Zone 2, 5 min." — time in each zone that saw activity. + private var spokenSummary: String { + activeZones + .map { "Zone \($0 + 1), \(spokenDuration(Int(zoneSeconds[$0].rounded())))" } + .joined(separator: ". ") + } + + private func spokenDuration(_ seconds: Int) -> String { + let m = seconds / 60, s = seconds % 60 + if m > 0 && s > 0 { return "\(m) min \(s) sec" } + if m > 0 { return "\(m) min" } + return "\(s) sec" } }