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:
2026-07-08 07:59:30 -04:00
parent 1b399ee7ba
commit e04eb83e70
7 changed files with 158 additions and 43 deletions
@@ -150,31 +150,69 @@ struct MetricTile: View {
}
}
/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (lowhigh).
/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (lowhigh),
/// 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..<min(zoneSeconds.count, colors.count)).filter { zoneSeconds[$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"
}
}