Skip to content

Additional accessibilityLabels for VoiceOver users (take #3) #1228

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 21, 2025
Merged
Show file tree
Hide file tree
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
480 changes: 479 additions & 1 deletion Localizable.xcstrings

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions Meshtastic/Extensions/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ extension String {
.joined()
}

/// Formats a short name like "P130" to read as "Node P 130" for VoiceOver
/// This ensures proper pronunciation of alphanumeric node IDs
func formatNodeNameForVoiceOver() -> String {
let spaced = self.replacingOccurrences(
of: #"([A-Za-z])([0-9]+)"#,
with: "$1 $2",
options: .regularExpression
)
return "Node " + spaced
Copy link
Preview

Copilot AI May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider localizing the 'Node ' prefix to support multiple languages in the VoiceOver output.

Copilot uses AI. Check for mistakes.

}

// Adds variation selectors to prefer the graphical form of emoji.
// Looks ahead to make sure that the variation selector is not already applied.
var addingVariationSelectors: String {
Expand Down
81 changes: 49 additions & 32 deletions Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,47 +32,64 @@ import Foundation
import SwiftUI

struct SignalStrengthIndicator: View {
let signalStrength: BLESignalStrength
// Accessibility: VoiceOver description
private var accessibilityDescription: String {
switch signalStrength {
case .weak:
return NSLocalizedString("ble.signal.strength.weak", comment: "VoiceOver value for weak BLE signal strength")
case .normal:
return NSLocalizedString("ble.signal.strength.normal", comment: "VoiceOver value for normal BLE signal strength")
case .strong:
return NSLocalizedString("ble.signal.strength.strong", comment: "VoiceOver value for strong BLE signal strength")
}
}

var body: some View {
HStack {
ForEach(0..<3) { bar in
RoundedRectangle(cornerRadius: 3)
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
.frame(width: 8, height: 40)
}
}
}
let signalStrength: BLESignalStrength

private func getColor() -> Color {
switch signalStrength {
case .weak:
return Color.red
case .normal:
return Color.yellow
case .strong:
return Color.green
}
}
var body: some View {
Group {
HStack {
ForEach(0..<3) { bar in
RoundedRectangle(cornerRadius: 3)
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
.frame(width: 8, height: 40)
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(NSLocalizedString("signal_strength", comment: "VoiceOver label for signal strength indicator"))
.accessibilityValue(accessibilityDescription)
}

private func getColor() -> Color {
switch signalStrength {
case .weak:
return Color.red
case .normal:
return Color.yellow
case .strong:
return Color.green
}
}
}

struct Divided<S: Shape>: Shape {
var amount: CGFloat // Should be in range 0...1
var shape: S
func path(in rect: CGRect) -> Path {
shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
}
var amount: CGFloat // Should be in range 0...1
var shape: S
func path(in rect: CGRect) -> Path {
shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
}
}

extension Shape {
func divided(amount: CGFloat) -> Divided<Self> {
return Divided(amount: amount, shape: self)
}
func divided(amount: CGFloat) -> Divided<Self> {
return Divided(amount: amount, shape: self)
}
}

enum BLESignalStrength: Int {
case weak = 0
case normal = 1
case strong = 2
case weak = 0
case normal = 1
case strong = 2
}
75 changes: 55 additions & 20 deletions Meshtastic/Views/Helpers/BatteryCompact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,69 +13,104 @@ struct BatteryCompact: View {
var color: Color

var body: some View {
// Group the battery icon and label in a single accessible container
HStack(alignment: .center, spacing: 0) {
if let batteryLevel {
if batteryLevel == 100 {
// Check for plugged in state
let isPluggedIn = batteryLevel > 100
let isCharging = batteryLevel == 100

// Battery icon selection based on level
if isPluggedIn {
Image(systemName: "powerplug")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true) // Hide from VoiceOver since container will handle it
} else if isCharging {
Image(systemName: "battery.100.bolt")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 100 && batteryLevel > 74 {
.accessibilityHidden(true)
} else if batteryLevel > 74 {
Image(systemName: "battery.75")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 75 && batteryLevel > 49 {
.accessibilityHidden(true)
} else if batteryLevel > 49 {
Image(systemName: "battery.50")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 50 && batteryLevel > 14 {
.accessibilityHidden(true)
} else if batteryLevel > 14 {
Image(systemName: "battery.25")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 15 && batteryLevel > 0 {
.accessibilityHidden(true)
} else if batteryLevel > 0 {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel == 0 {
.accessibilityHidden(true)
} else {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(.red)
.symbolRenderingMode(.multicolor)
} else if batteryLevel > 100 {
Image(systemName: "powerplug")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
}
} else {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
}
if let batteryLevel {
if batteryLevel > 100 {

// Battery text label
if isPluggedIn {
Text("PWD")
.foregroundStyle(.secondary)
.font(font)
} else if batteryLevel == 100 {
.accessibilityHidden(true)
} else if isCharging {
Text("CHG")
.foregroundStyle(.secondary)
.font(font)
.accessibilityHidden(true)
} else {
Text(verbatim: "\(batteryLevel.formatted(.number.precision(.fractionLength(0))))%")
.foregroundStyle(.secondary)
.font(font)
.accessibilityHidden(true)
}
} else {
// Unknown battery state
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)

Text(verbatim: "?")
.foregroundStyle(.secondary)
.font(font)
.accessibilityHidden(true)
}
}
// Setup container-level accessibility for VoiceOver
.accessibilityElement(children: .ignore)
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
// Set appropriate value based on the battery state using a computed property
.accessibilityValue(batteryLevel.map { level in
if level > 100 {
// Plugged in - same as PWD visual indicator
return NSLocalizedString("device_plugged_in", comment: "VoiceOver value for plugged in device")
} else if level == 100 {
// Charging - same as CHG visual indicator
return NSLocalizedString("device_charging", comment: "VoiceOver value for charging device")
} else {
// Normal battery level
return String(format: NSLocalizedString("battery_level_percent", comment: "VoiceOver value for battery level"), Int(level))
}
} ?? "Unknown")
}
}
35 changes: 28 additions & 7 deletions Meshtastic/Views/Helpers/BatteryGauge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@ struct BatteryGauge: View {

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

VStack {
if batteryLevel > 100.0 {
// Plugged in
Image(systemName: "powerplug")
.font(.largeTitle)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
if isPluggedIn {
// Use a completely standalone view for the plugged in state
// to avoid any VoiceOver confusion
PluggedInIndicator()
} else {
let gradient = Gradient(colors: [.red, .orange, .green])
Gauge(value: batteryLevel, in: minValue...maxValue) {
// Accessibility for battery gauge
if batteryLevel >= 0.0 && batteryLevel < 10 {
Label("Battery Level %", systemImage: "battery.0")
} else if batteryLevel >= 10.0 && batteryLevel < 25.00 {
Expand All @@ -50,6 +52,8 @@ struct BatteryGauge: View {
Text(Int(batteryLevel), format: .percent)
}
}
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
.accessibilityValue(String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(batteryLevel)))
.tint(gradient)
.gaugeStyle(.accessoryCircular)
}
Expand All @@ -63,6 +67,23 @@ struct BatteryGauge: View {
}
}

/// A dedicated view for showing a device is plugged in
/// With proper VoiceOver support that matches the visual indication
struct PluggedInIndicator: View {
var body: some View {
// This view is isolated from any battery measurement
// to ensure VoiceOver doesn't pick up any percentages
Image(systemName: "powerplug")
.font(.largeTitle)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
// Override the accessibility to ensure correct VoiceOver announcement
.accessibilityElement(children: .ignore)
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
.accessibilityValue(NSLocalizedString("device_plugged_in", comment: "VoiceOver value for plugged in device"))
}
}

struct BatteryGauge_Previews: PreviewProvider {
static var previews: some View {
VStack {
Expand Down
50 changes: 37 additions & 13 deletions Meshtastic/Views/Helpers/ConnectedDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,46 @@ struct ConnectedDevice: View {
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
if bluetoothOn {
if deviceConnected {
if mqttUplinkEnabled || mqttDownlinkEnabled {
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
}
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
.imageScale(.large)
.foregroundColor(.green)
.symbolRenderingMode(.hierarchical)
Text(name.addingVariationSelectors).font(name.isEmoji() ? .title : .callout).foregroundColor(.gray)
// Create an HStack for connected state with proper accessibility
HStack {
if mqttUplinkEnabled || mqttDownlinkEnabled {
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
.accessibilityHidden(true)
}
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
.imageScale(.large)
.foregroundColor(.green)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
Text(name.addingVariationSelectors)
.font(name.isEmoji() ? .title : .callout)
.foregroundColor(.gray)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("bluetooth.connected".localized + ", " + name.formatNodeNameForVoiceOver())
} else {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
// Create a container for disconnected state
HStack {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("bluetooth.not.connected".localized)
}
} else {
Text("Bluetooth is off").font(.subheadline).foregroundColor(.red)
// Create a container for Bluetooth off state
HStack {
Text("bluetooth.off".localized)
.font(.subheadline)
.foregroundColor(.red)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("bluetooth.off".localized)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct RequestPositionButton: View {
var body: some View {
Button(action: action) {
Image(systemName: "mappin.and.ellipse")
.accessibilityLabel(NSLocalizedString("request_position", comment: "VoiceOver label for request position button"))
.symbolRenderingMode(.hierarchical)
.imageScale(.large)
.foregroundColor(.accentColor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ struct TextMessageSize: View {

var body: some View {
ProgressView("\("Bytes".localized): \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes))
.accessibilityLabel(NSLocalizedString("message_size", comment: "VoiceOver label for message size"))
.accessibilityValue(String(format: NSLocalizedString("bytes_used", comment: "VoiceOver value for bytes used"), totalBytes, maxbytes))
.frame(width: 130)
.padding(5)
.font(.subheadline)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct IgnoreNodeButton: View {
Image(systemName: node.ignored ? "minus.circle.fill" : "minus.circle")
.symbolRenderingMode(.multicolor)
}
// Accessibility: Label for VoiceOver
}
}
}
Loading