Skip to content

Commit b0fddba

Browse files
authored
Merge pull request #1228 from gitbisector/voiceovertake3
Additional accessibilityLabels for VoiceOver users (take #3)
2 parents 45f7aed + b0f1dbf commit b0fddba

13 files changed

+855
-144
lines changed

Localizable.xcstrings

Lines changed: 479 additions & 1 deletion
Large diffs are not rendered by default.

Meshtastic/Extensions/String.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ extension String {
115115
.joined()
116116
}
117117

118+
/// Formats a short name like "P130" to read as "Node P 130" for VoiceOver
119+
/// This ensures proper pronunciation of alphanumeric node IDs
120+
func formatNodeNameForVoiceOver() -> String {
121+
let spaced = self.replacingOccurrences(
122+
of: #"([A-Za-z])([0-9]+)"#,
123+
with: "$1 $2",
124+
options: .regularExpression
125+
)
126+
return "Node " + spaced
127+
}
128+
118129
// Adds variation selectors to prefer the graphical form of emoji.
119130
// Looks ahead to make sure that the variation selector is not already applied.
120131
var addingVariationSelectors: String {

Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,47 +32,64 @@ import Foundation
3232
import SwiftUI
3333

3434
struct SignalStrengthIndicator: View {
35-
let signalStrength: BLESignalStrength
35+
// Accessibility: VoiceOver description
36+
private var accessibilityDescription: String {
37+
switch signalStrength {
38+
case .weak:
39+
return NSLocalizedString("ble.signal.strength.weak", comment: "VoiceOver value for weak BLE signal strength")
40+
case .normal:
41+
return NSLocalizedString("ble.signal.strength.normal", comment: "VoiceOver value for normal BLE signal strength")
42+
case .strong:
43+
return NSLocalizedString("ble.signal.strength.strong", comment: "VoiceOver value for strong BLE signal strength")
44+
}
45+
}
3646

37-
var body: some View {
38-
HStack {
39-
ForEach(0..<3) { bar in
40-
RoundedRectangle(cornerRadius: 3)
41-
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
42-
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
43-
.frame(width: 8, height: 40)
44-
}
45-
}
46-
}
47+
let signalStrength: BLESignalStrength
4748

48-
private func getColor() -> Color {
49-
switch signalStrength {
50-
case .weak:
51-
return Color.red
52-
case .normal:
53-
return Color.yellow
54-
case .strong:
55-
return Color.green
56-
}
57-
}
49+
var body: some View {
50+
Group {
51+
HStack {
52+
ForEach(0..<3) { bar in
53+
RoundedRectangle(cornerRadius: 3)
54+
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
55+
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
56+
.frame(width: 8, height: 40)
57+
}
58+
}
59+
}
60+
.accessibilityElement(children: .ignore)
61+
.accessibilityLabel(NSLocalizedString("signal_strength", comment: "VoiceOver label for signal strength indicator"))
62+
.accessibilityValue(accessibilityDescription)
63+
}
64+
65+
private func getColor() -> Color {
66+
switch signalStrength {
67+
case .weak:
68+
return Color.red
69+
case .normal:
70+
return Color.yellow
71+
case .strong:
72+
return Color.green
73+
}
74+
}
5875
}
5976

6077
struct Divided<S: Shape>: Shape {
61-
var amount: CGFloat // Should be in range 0...1
62-
var shape: S
63-
func path(in rect: CGRect) -> Path {
64-
shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
65-
}
78+
var amount: CGFloat // Should be in range 0...1
79+
var shape: S
80+
func path(in rect: CGRect) -> Path {
81+
shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
82+
}
6683
}
6784

6885
extension Shape {
69-
func divided(amount: CGFloat) -> Divided<Self> {
70-
return Divided(amount: amount, shape: self)
71-
}
86+
func divided(amount: CGFloat) -> Divided<Self> {
87+
return Divided(amount: amount, shape: self)
88+
}
7289
}
7390

7491
enum BLESignalStrength: Int {
75-
case weak = 0
76-
case normal = 1
77-
case strong = 2
92+
case weak = 0
93+
case normal = 1
94+
case strong = 2
7895
}

Meshtastic/Views/Helpers/BatteryCompact.swift

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,69 +13,104 @@ struct BatteryCompact: View {
1313
var color: Color
1414

1515
var body: some View {
16+
// Group the battery icon and label in a single accessible container
1617
HStack(alignment: .center, spacing: 0) {
1718
if let batteryLevel {
18-
if batteryLevel == 100 {
19+
// Check for plugged in state
20+
let isPluggedIn = batteryLevel > 100
21+
let isCharging = batteryLevel == 100
22+
23+
// Battery icon selection based on level
24+
if isPluggedIn {
25+
Image(systemName: "powerplug")
26+
.font(iconFont)
27+
.foregroundColor(color)
28+
.symbolRenderingMode(.multicolor)
29+
.accessibilityHidden(true) // Hide from VoiceOver since container will handle it
30+
} else if isCharging {
1931
Image(systemName: "battery.100.bolt")
2032
.font(iconFont)
2133
.foregroundColor(color)
2234
.symbolRenderingMode(.multicolor)
23-
} else if batteryLevel < 100 && batteryLevel > 74 {
35+
.accessibilityHidden(true)
36+
} else if batteryLevel > 74 {
2437
Image(systemName: "battery.75")
2538
.font(iconFont)
2639
.foregroundColor(color)
2740
.symbolRenderingMode(.multicolor)
28-
} else if batteryLevel < 75 && batteryLevel > 49 {
41+
.accessibilityHidden(true)
42+
} else if batteryLevel > 49 {
2943
Image(systemName: "battery.50")
3044
.font(iconFont)
3145
.foregroundColor(color)
3246
.symbolRenderingMode(.multicolor)
33-
} else if batteryLevel < 50 && batteryLevel > 14 {
47+
.accessibilityHidden(true)
48+
} else if batteryLevel > 14 {
3449
Image(systemName: "battery.25")
3550
.font(iconFont)
3651
.foregroundColor(color)
3752
.symbolRenderingMode(.multicolor)
38-
} else if batteryLevel < 15 && batteryLevel > 0 {
53+
.accessibilityHidden(true)
54+
} else if batteryLevel > 0 {
3955
Image(systemName: "battery.0")
4056
.font(iconFont)
4157
.foregroundColor(color)
4258
.symbolRenderingMode(.multicolor)
43-
} else if batteryLevel == 0 {
59+
.accessibilityHidden(true)
60+
} else {
4461
Image(systemName: "battery.0")
4562
.font(iconFont)
4663
.foregroundColor(.red)
4764
.symbolRenderingMode(.multicolor)
48-
} else if batteryLevel > 100 {
49-
Image(systemName: "powerplug")
50-
.font(iconFont)
51-
.foregroundColor(color)
52-
.symbolRenderingMode(.multicolor)
65+
.accessibilityHidden(true)
5366
}
54-
} else {
55-
Image(systemName: "battery.0")
56-
.font(iconFont)
57-
.foregroundColor(color)
58-
.symbolRenderingMode(.multicolor)
59-
}
60-
if let batteryLevel {
61-
if batteryLevel > 100 {
67+
68+
// Battery text label
69+
if isPluggedIn {
6270
Text("PWD")
6371
.foregroundStyle(.secondary)
6472
.font(font)
65-
} else if batteryLevel == 100 {
73+
.accessibilityHidden(true)
74+
} else if isCharging {
6675
Text("CHG")
6776
.foregroundStyle(.secondary)
6877
.font(font)
78+
.accessibilityHidden(true)
6979
} else {
7080
Text(verbatim: "\(batteryLevel.formatted(.number.precision(.fractionLength(0))))%")
7181
.foregroundStyle(.secondary)
7282
.font(font)
83+
.accessibilityHidden(true)
7384
}
7485
} else {
86+
// Unknown battery state
87+
Image(systemName: "battery.0")
88+
.font(iconFont)
89+
.foregroundColor(color)
90+
.symbolRenderingMode(.multicolor)
91+
.accessibilityHidden(true)
92+
7593
Text(verbatim: "?")
7694
.foregroundStyle(.secondary)
7795
.font(font)
96+
.accessibilityHidden(true)
7897
}
7998
}
99+
// Setup container-level accessibility for VoiceOver
100+
.accessibilityElement(children: .ignore)
101+
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
102+
// Set appropriate value based on the battery state using a computed property
103+
.accessibilityValue(batteryLevel.map { level in
104+
if level > 100 {
105+
// Plugged in - same as PWD visual indicator
106+
return NSLocalizedString("device_plugged_in", comment: "VoiceOver value for plugged in device")
107+
} else if level == 100 {
108+
// Charging - same as CHG visual indicator
109+
return NSLocalizedString("device_charging", comment: "VoiceOver value for charging device")
110+
} else {
111+
// Normal battery level
112+
return String(format: NSLocalizedString("battery_level_percent", comment: "VoiceOver value for battery level"), Int(level))
113+
}
114+
} ?? "Unknown")
80115
}
81116
}

Meshtastic/Views/Helpers/BatteryGauge.swift

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,20 @@ struct BatteryGauge: View {
1818

1919
let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0"))
2020
let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity
21-
let batteryLevel = Double(mostRecent?.batteryLevel ?? 0)
21+
// For VoiceOver purposes, detect when device is plugged in (battery > 100%)
22+
let isPluggedIn = (mostRecent?.batteryLevel ?? 0) > 100
23+
// Use a capped battery level for UI display
24+
let batteryLevel = Double(min(100, mostRecent?.batteryLevel ?? 0))
2225

2326
VStack {
24-
if batteryLevel > 100.0 {
25-
// Plugged in
26-
Image(systemName: "powerplug")
27-
.font(.largeTitle)
28-
.foregroundColor(.accentColor)
29-
.symbolRenderingMode(.hierarchical)
27+
if isPluggedIn {
28+
// Use a completely standalone view for the plugged in state
29+
// to avoid any VoiceOver confusion
30+
PluggedInIndicator()
3031
} else {
3132
let gradient = Gradient(colors: [.red, .orange, .green])
3233
Gauge(value: batteryLevel, in: minValue...maxValue) {
34+
// Accessibility for battery gauge
3335
if batteryLevel >= 0.0 && batteryLevel < 10 {
3436
Label("Battery Level %", systemImage: "battery.0")
3537
} else if batteryLevel >= 10.0 && batteryLevel < 25.00 {
@@ -50,6 +52,8 @@ struct BatteryGauge: View {
5052
Text(Int(batteryLevel), format: .percent)
5153
}
5254
}
55+
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
56+
.accessibilityValue(String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(batteryLevel)))
5357
.tint(gradient)
5458
.gaugeStyle(.accessoryCircular)
5559
}
@@ -63,6 +67,23 @@ struct BatteryGauge: View {
6367
}
6468
}
6569

70+
/// A dedicated view for showing a device is plugged in
71+
/// With proper VoiceOver support that matches the visual indication
72+
struct PluggedInIndicator: View {
73+
var body: some View {
74+
// This view is isolated from any battery measurement
75+
// to ensure VoiceOver doesn't pick up any percentages
76+
Image(systemName: "powerplug")
77+
.font(.largeTitle)
78+
.foregroundColor(.accentColor)
79+
.symbolRenderingMode(.hierarchical)
80+
// Override the accessibility to ensure correct VoiceOver announcement
81+
.accessibilityElement(children: .ignore)
82+
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
83+
.accessibilityValue(NSLocalizedString("device_plugged_in", comment: "VoiceOver value for plugged in device"))
84+
}
85+
}
86+
6687
struct BatteryGauge_Previews: PreviewProvider {
6788
static var previews: some View {
6889
VStack {

Meshtastic/Views/Helpers/ConnectedDevice.swift

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,46 @@ struct ConnectedDevice: View {
2121
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
2222
if bluetoothOn {
2323
if deviceConnected {
24-
if mqttUplinkEnabled || mqttDownlinkEnabled {
25-
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
26-
}
27-
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
28-
.imageScale(.large)
29-
.foregroundColor(.green)
30-
.symbolRenderingMode(.hierarchical)
31-
Text(name.addingVariationSelectors).font(name.isEmoji() ? .title : .callout).foregroundColor(.gray)
24+
// Create an HStack for connected state with proper accessibility
25+
HStack {
26+
if mqttUplinkEnabled || mqttDownlinkEnabled {
27+
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
28+
.accessibilityHidden(true)
29+
}
30+
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
31+
.imageScale(.large)
32+
.foregroundColor(.green)
33+
.symbolRenderingMode(.hierarchical)
34+
.accessibilityHidden(true)
35+
Text(name.addingVariationSelectors)
36+
.font(name.isEmoji() ? .title : .callout)
37+
.foregroundColor(.gray)
38+
.accessibilityHidden(true)
39+
}
40+
.accessibilityElement(children: .ignore)
41+
.accessibilityLabel("bluetooth.connected".localized + ", " + name.formatNodeNameForVoiceOver())
3242
} else {
33-
Image(systemName: "antenna.radiowaves.left.and.right.slash")
34-
.imageScale(.medium)
35-
.foregroundColor(.red)
36-
.symbolRenderingMode(.hierarchical)
43+
// Create a container for disconnected state
44+
HStack {
45+
Image(systemName: "antenna.radiowaves.left.and.right.slash")
46+
.imageScale(.medium)
47+
.foregroundColor(.red)
48+
.symbolRenderingMode(.hierarchical)
49+
.accessibilityHidden(true)
50+
}
51+
.accessibilityElement(children: .ignore)
52+
.accessibilityLabel("bluetooth.not.connected".localized)
3753
}
3854
} else {
39-
Text("Bluetooth is off").font(.subheadline).foregroundColor(.red)
55+
// Create a container for Bluetooth off state
56+
HStack {
57+
Text("bluetooth.off".localized)
58+
.font(.subheadline)
59+
.foregroundColor(.red)
60+
.accessibilityHidden(true)
61+
}
62+
.accessibilityElement(children: .ignore)
63+
.accessibilityLabel("bluetooth.off".localized)
4064
}
4165
}
4266
}

Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ struct RequestPositionButton: View {
66
var body: some View {
77
Button(action: action) {
88
Image(systemName: "mappin.and.ellipse")
9+
.accessibilityLabel(NSLocalizedString("request_position", comment: "VoiceOver label for request position button"))
910
.symbolRenderingMode(.hierarchical)
1011
.imageScale(.large)
1112
.foregroundColor(.accentColor)

Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ struct TextMessageSize: View {
66

77
var body: some View {
88
ProgressView("\("Bytes".localized): \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes))
9+
.accessibilityLabel(NSLocalizedString("message_size", comment: "VoiceOver label for message size"))
10+
.accessibilityValue(String(format: NSLocalizedString("bytes_used", comment: "VoiceOver value for bytes used"), totalBytes, maxbytes))
911
.frame(width: 130)
1012
.padding(5)
1113
.font(.subheadline)

Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ struct IgnoreNodeButton: View {
4040
Image(systemName: node.ignored ? "minus.circle.fill" : "minus.circle")
4141
.symbolRenderingMode(.multicolor)
4242
}
43+
// Accessibility: Label for VoiceOver
4344
}
4445
}
4546
}

0 commit comments

Comments
 (0)