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:
@@ -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..<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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user