Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions StrandiOSWidgets/NOOPWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,18 @@ struct NOOPWidgetView: View {
scoreRings(diameter: 88, lineWidth: 8, labelFont: .caption)
Divider()
HStack(alignment: .top, spacing: 0) {
statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms")
statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm")
statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm")
statCell("Battery", value: snap.batteryPct.map { "\($0)%" })
statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms",
name: "Heart rate variability",
spoken: snap.hrv.map { "\($0) milliseconds" })
statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm",
name: "Resting heart rate",
spoken: snap.restingHr.map { "\($0) beats per minute" })
statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm",
name: "Heart rate",
spoken: snap.bpm.map { "\($0) beats per minute" })
statCell("Battery", value: snap.batteryPct.map { "\($0)%" },
name: "Strap battery",
spoken: snap.batteryPct.map { "\($0) percent" })
}
Spacer(minLength: 0)
}
Expand Down Expand Up @@ -273,24 +281,66 @@ struct NOOPWidgetView: View {
.frame(maxWidth: .infinity)
}

/// Home-Screen footer: heart rate, HRV, strap battery, under the score rings.
///
/// `heart.fill` is HR and `waveform.path.ecg` is HRV, which is the metric pairing the rest of the app
/// uses — `TodayView` renders them as adjacent rows that way, and `DashboardCards`,
/// `TodayCustomizationMetadata`, `MetricCatalog` and this extension's own Live Activity all agree.
/// The two were inverted here from #1022 until #1795 reported it.
///
/// The inversion was an easy one to make and is worth naming so it is not "corrected" back:
/// `waveform.path.ecg` IS the right symbol for Live HR as a FEATURE — `RootView`, `RootTabView` and
/// `HomeScreenQuickActions` all use it for the Live screen. As a METRIC icon it belongs to HRV, and
/// this footer is the one place that renders both metrics side by side, where the collision is the
/// entire problem: nothing here carries a text label, so the glyph is the only thing naming a number.
private func vitalsFooter(compact: Bool) -> some View {
HStack {
Label(snap.bpm.map(String.init) ?? "–", systemImage: "waveform.path.ecg")
vital(symbol: "heart.fill", text: snap.bpm.map(String.init),
name: "Heart rate", spoken: snap.bpm.map { "\($0) beats per minute" })
Spacer()
if !compact, let hrv = snap.hrv {
Label("\(hrv)", systemImage: "heart.fill")
vital(symbol: "waveform.path.ecg", text: "\(hrv)",
name: "Heart rate variability", spoken: "\(hrv) milliseconds")
Spacer()
}
Label(snap.batteryPct.map { "\($0)%" } ?? "–", systemImage: "battery.50")
vital(symbol: "battery.50", text: snap.batteryPct.map { "\($0)%" },
name: "Strap battery", spoken: snap.batteryPct.map { "\($0) percent" })
}
.font(.caption2)
.foregroundStyle(StrandPalette.textSecondary)
.labelStyle(.titleAndIcon)
}

/// One footer vital. `spoken` is carried separately from the rendered `text` because VoiceOver gets
/// neither of the two things a sighted reader uses here: the glyph that names the metric, and the
/// unit, which the footer shows for none of them. Without this a reader hears "58", "64", "84 percent"
/// — three anonymous numbers. Same reasoning as `accessoryScore`, which #1715 fixed for the lock
/// screen; this footer predates it.
///
/// Plain literals rather than `String(localized:)`, for the reason spelled out on `accessoryScore`:
/// this extension does not carry the app's string catalog, so `String(localized:)` would look
/// localized and render English anyway.
private func vital(symbol: String, text: String?, name: String, spoken: String?) -> some View {
Label(text ?? "–", systemImage: symbol)
// Collapse first, like `accessoryScore` and `WidgetScoreRing` already do. A `Label` under
// `.titleAndIcon` renders an image beside a text, so without this the bare number stays its
// own element and whether the label below wins is SwiftUI container semantics rather than
// something this file decides. Every other labelled graphic here ignores its children.
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(name))
.accessibilityValue(Text(spoken ?? "No data"))
}

/// One labelled stat in the large grid — value over a caption, equal-width so the columns align.
///
/// `name` and `spoken` exist because the on-screen caption is abbreviated for width and the unit is a
/// separate view: read as-is, VoiceOver produces "64", "ms", "HRV" — three fragments, value before
/// label, with "ms" and "bpm" spelled out letter by letter. Collapsing to one element lets the cell
/// speak "Heart rate variability, 64 milliseconds". `spoken` falls back to the rendered value rather
/// than to "No data", so a caller that omits it degrades to the old reading instead of lying.
private func statCell(_ label: String, value: String?, unit: String? = nil,
tint: Color = StrandPalette.textPrimary) -> some View {
tint: Color = StrandPalette.textPrimary,
name: String? = nil, spoken: String? = nil) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value ?? "–")
Expand All @@ -303,6 +353,9 @@ struct NOOPWidgetView: View {
Text(label).font(.caption2).foregroundStyle(StrandPalette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(name ?? label))
.accessibilityValue(Text(spoken ?? value ?? "No data"))
}
}

Expand Down
Loading