Merge pull request #1224 from meshtastic/revert-1221-voiceovertake2

Revert "Additional accessibilityLabels for VoiceOver users (take #2)"
This commit is contained in:
Garth Vander Houwen 2025-05-13 06:32:55 -07:00 committed by GitHub
commit e3316a0e62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 154 additions and 895 deletions

View file

@ -35346,485 +35346,7 @@
}
}
}
},
"ble.signal.strength.weak" : {
"comment" : "VoiceOver value for weak BLE signal strength",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Signalstärke schwach"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Signal strength weak"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Segnale debole"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Слаб сигнал"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "信号弱"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "訊號微弱"
}
}
}
},
"signal_strength" : {
"comment" : "VoiceOver label for signal strength indicator",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Signalstärke"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Signal strength"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Intensità del segnale"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Јачина сигнала"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "信号强度"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "訊號強度"
}
}
}
},
"message_size" : {
"comment" : "VoiceOver label for message size",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nachrichtengröße"
}
},
"en" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Message size"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Dimensione messaggio"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Величина поруке"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "消息大小"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "訊息大小"
}
}
}
},
"device_charging" : {
"comment" : "VoiceOver value for charging device",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Charging"
}
}
}
},
"Bluetooth is off.off" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bluetooth ist aus"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Le Bluetooth est arrêté"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "בלוטוס כבוי"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Il Bluetooth è spento"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bluetooth jest wyłączony"
}
},
"se" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bluetooth är avstängt"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Блутут је искључен"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "蓝牙已关闭"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "藍芽已關閉"
}
}
}
},
"bytes_used" : {
"comment" : "VoiceOver value for bytes used",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "%d von %d Bytes verwendet"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%d of %d bytes used"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "%d di %d byte usati"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "%d од %d бајтова искоришћено"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "已用%d/%d字节"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "已用%d/%d位元組"
}
}
}
},
"heading" : {
"comment" : "Heading label for VoiceOver"
},
"Hide sidebar" : {},
"bluetooth.not.connected" : {
"comment" : "VoiceOver label for disconnected Bluetooth icon",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No Bluetooth device connected"
}
}
}
},
"device.configuration" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Gerätekonfiguration"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Device Configuration"
}
},
"he" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Device Configuration"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Configurazione del dispositivo"
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Device Configuration"
}
},
"se" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Enhetsinställningar"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Подешавања уређаја"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "设备配置"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "設備設定"
}
}
}
},
"ble.signal.strength.strong" : {
"comment" : "VoiceOver value for strong BLE signal strength",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Signalstärke stark"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Signal strength strong"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Segnale forte"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Јак сигнал"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "信号强"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "訊號強"
}
}
}
},
"ble.signal.strength.normal" : {
"comment" : "VoiceOver value for normal BLE signal strength",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Signalstärke normal"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Signal strength normal"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Segnale normale"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Нормалан сигнал"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "信号正常"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "訊號正常"
}
}
}
},
"bluetooth.connected" : {
"comment" : "VoiceOver label for connected Bluetooth icon",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connected to Bluetooth device"
}
}
}
},
"request_position" : {
"comment" : "VoiceOver label for request position button",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Position anfordern"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Request position"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Richiedi posizione"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Захтевај позицију"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请求位置"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "請求位置"
}
}
}
},
"distance" : {
"comment" : "Distance label for VoiceOver"
},
"device_plugged_in" : {
"comment" : "VoiceOver value for plugged in device",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Plugged in"
}
}
}
},
"unknown" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "sconosciuto"
}
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "непознато"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "未知"
}
}
}
}
},
"version" : "1.0"
}
}

View file

@ -115,17 +115,6 @@ 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
}
// 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 {

View file

@ -27,31 +27,6 @@ struct Connect: View {
@State var presentingSwitchPreferredPeripheral = false
@State var selectedPeripherialId = ""
private func nodeAccessibilityLabel() -> String {
// Create a battery status string that handles charging and plugged in states
var batteryStatus: String? = nil
if let batteryLevel = node?.latestDeviceMetrics?.batteryLevel {
if batteryLevel > 100 {
// Plugged in state
batteryStatus = NSLocalizedString("device_plugged_in", comment: "VoiceOver value for plugged in device")
} else if batteryLevel == 100 {
// Charging state
batteryStatus = NSLocalizedString("device_charging", comment: "VoiceOver value for charging device")
} else {
// Normal battery percentage
batteryStatus = "Battery: \(Int(batteryLevel))%"
}
}
return [
node?.user?.shortName?.formatNodeNameForVoiceOver() ?? "",
"BLE Name: \(bleManager.connectedPeripheral?.peripheral.name?.addingVariationSelectors ?? "unknown".localized)",
"Firmware Version: \(node?.metadata?.firmwareVersion ?? "unknown".localized)",
bleManager.isSubscribed ? "Subscribed" : nil,
batteryStatus
].compactMap { $0 }.joined(separator: ", ")
}
init () {
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.getNotificationSettings(completionHandler: { (settings) in
@ -111,8 +86,6 @@ struct Connect: View {
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(nodeAccessibilityLabel())
.font(.caption)
.foregroundColor(Color.gray)
.padding([.top])
@ -326,8 +299,6 @@ struct Connect: View {
mqttTopic: bleManager.mqttManager.topic
)
}
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
.accessibilityElement(children: .contain)
)
}
.sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) {

View file

@ -32,65 +32,47 @@ import Foundation
import SwiftUI
struct SignalStrengthIndicator: View {
// 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")
}
}
let signalStrength: BLESignalStrength
let signalStrength: BLESignalStrength
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)
}
}
}
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)
accessibilityHidden(true) // Ensures bars are ignored
}
}
}
.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
}
}
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
}

View file

@ -13,104 +13,69 @@ 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 {
// 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 {
if batteryLevel == 100 {
Image(systemName: "battery.100.bolt")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 74 {
} else if batteryLevel < 100 && batteryLevel > 74 {
Image(systemName: "battery.75")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 49 {
} else if batteryLevel < 75 && batteryLevel > 49 {
Image(systemName: "battery.50")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 14 {
} else if batteryLevel < 50 && batteryLevel > 14 {
Image(systemName: "battery.25")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 0 {
} else if batteryLevel < 15 && batteryLevel > 0 {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else {
} else if batteryLevel == 0 {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(.red)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
}
// Battery text label
if isPluggedIn {
Text("PWD")
.foregroundStyle(.secondary)
.font(font)
.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 if batteryLevel > 100 {
Image(systemName: "powerplug")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
}
} else {
// Unknown battery state
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
}
if let batteryLevel {
if batteryLevel > 100 {
Text("PWD")
.foregroundStyle(.secondary)
.font(font)
} else if batteryLevel == 100 {
Text("CHG")
.foregroundStyle(.secondary)
.font(font)
} else {
Text(verbatim: "\(batteryLevel.formatted(.number.precision(.fractionLength(0))))%")
.foregroundStyle(.secondary)
.font(font)
}
} else {
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")
}
}

View file

@ -18,20 +18,18 @@ struct BatteryGauge: View {
let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0"))
let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity
// 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))
let batteryLevel = Double(mostRecent?.batteryLevel ?? 0)
VStack {
if isPluggedIn {
// Use a completely standalone view for the plugged in state
// to avoid any VoiceOver confusion
PluggedInIndicator()
if batteryLevel > 100.0 {
// Plugged in
Image(systemName: "powerplug")
.font(.largeTitle)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
} 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 {
@ -52,8 +50,6 @@ 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)
}
@ -67,23 +63,6 @@ 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 {

View file

@ -21,46 +21,22 @@ struct ConnectedDevice: View {
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
if bluetoothOn {
if deviceConnected {
// 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())
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)
} else {
// 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)
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
}
} else {
// 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)
Text("Bluetooth is off").font(.subheadline).foregroundColor(.red)
}
}
}

View file

@ -6,7 +6,6 @@ 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)

View file

@ -6,8 +6,6 @@ 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)

View file

@ -40,7 +40,6 @@ struct IgnoreNodeButton: View {
Image(systemName: node.ignored ? "minus.circle.fill" : "minus.circle")
.symbolRenderingMode(.multicolor)
}
// Accessibility: Label for VoiceOver
}
}
}

View file

@ -46,8 +46,7 @@ struct NodeDetail: View {
Section("Hardware") {
NodeInfoItem(node: node)
}
.accessibilityElement(children: .combine)
Section("Node") { // Node
Section("Node") {
HStack(alignment: .center) {
Spacer()
CircleText(
@ -68,7 +67,6 @@ struct NodeDetail: View {
.foregroundColor(getRssiColor(rssi: node.rssi))
.font(.caption)
}
.accessibilityElement(children: .combine)
}
if node.telemetries?.count ?? 0 > 0 {
Spacer()
@ -76,7 +74,6 @@ struct NodeDetail: View {
}
Spacer()
}
.accessibilityElement(children: .combine)
.listRowSeparator(.hidden)
if let user = node.user {
if !user.keyMatch {
@ -89,7 +86,6 @@ struct NodeDetail: View {
.foregroundStyle(.secondary)
.font(.callout)
}
.accessibilityElement(children: .combine)
} icon: {
Image(systemName: "key.slash.fill")
.symbolRenderingMode(.multicolor)
@ -108,7 +104,6 @@ struct NodeDetail: View {
Text(String(node.num))
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
HStack {
Label {
@ -121,7 +116,6 @@ struct NodeDetail: View {
Text(node.num.toHex())
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
if let metadata = node.metadata {
HStack {
@ -135,7 +129,6 @@ struct NodeDetail: View {
Text(metadata.firmwareVersion ?? "Unknown".localized)
}
.accessibilityElement(children: .combine)
}
if let role = node.user?.role, let deviceRole = DeviceRoles(rawValue: Int(role)) {
@ -149,7 +142,6 @@ struct NodeDetail: View {
Spacer()
Text(deviceRole.name)
}
.accessibilityElement(children: .combine)
}
if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, let uptimeSeconds = dm.uptimeSeconds {
@ -169,7 +161,6 @@ struct NodeDetail: View {
Text(uptime)
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
}
if let firstHeard = node.firstHeard, firstHeard.timeIntervalSince1970 > 0 && firstHeard < Calendar.current.date(byAdding: .year, value: 1, to: Date())! {
@ -188,9 +179,7 @@ struct NodeDetail: View {
Text(firstHeard.formatted())
.textSelection(.enabled)
}
}
.accessibilityElement(children: .combine)
.onTapGesture {
}.onTapGesture {
dateFormatRelative.toggle()
}
}
@ -214,9 +203,7 @@ struct NodeDetail: View {
Text(lastHeard.formatted())
.textSelection(.enabled)
}
}
.accessibilityElement(children: .combine)
.onTapGesture {
}.onTapGesture {
dateFormatRelative.toggle()
}
}
@ -229,84 +216,79 @@ struct NodeDetail: View {
if node.hasPositions && UserDefaults.environmentEnableWeatherKit
|| node.hasDataForLatestEnvironmentMetrics(attributes: ["iaq", "temperature", "relativeHumidity", "barometricPressure", "windSpeed", "radiation", "weight", "Distance", "soilTemperature", "soilMoisture"]) {
Section("Environment") {
// Group weather/environment data for better VoiceOver experience
VStack {
if !node.hasEnvironmentMetrics {
LocalWeatherConditions(location: node.latestPosition?.nodeLocation)
} else {
VStack {
if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 {
IndoorAirQuality(iaq: Int(node.latestEnvironmentMetrics?.iaq ?? 0), displayMode: .gradient)
.padding(.vertical)
}
LazyVGrid(columns: gridItemLayout) {
if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() {
WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP")
}
if let humidity = node.latestEnvironmentMetrics?.relativeHumidity {
if let temperature = node.latestEnvironmentMetrics?.temperature {
let dewPoint = calculateDewPoint(temp: temperature, relativeHumidity: humidity)
.formatted(.number.precision(.fractionLength(0))) + "°"
HumidityCompactWidget(humidity: Int(humidity), dewPoint: dewPoint)
} else {
HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil)
}
}
if let pressure = node.latestEnvironmentMetrics?.barometricPressure {
PressureCompactWidget(pressure: pressure.formatted(.number.precision(.fractionLength(2))), unit: "hPA", low: pressure <= 1009.144)
}
if let windSpeed = node.latestEnvironmentMetrics?.windSpeed {
let windSpeedMeasurement = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond)
let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) }
let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0))
WindCompactWidget(speed: windSpeedMeasurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))),
gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction)
}
if let rainfall1h = node.latestEnvironmentMetrics?.rainfall1H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall1h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall1H, rainfall: formattedRain, unit: unitLabel)
}
if let rainfall24h = node.latestEnvironmentMetrics?.rainfall24H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall24h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall24H, rainfall: formattedRain, unit: unitLabel)
}
if let radiation = node.latestEnvironmentMetrics?.radiation {
RadiationCompactWidget(radiation: radiation.formatted(.number.precision(.fractionLength(1))), unit: "µR/hr")
}
if let weight = node.latestEnvironmentMetrics?.weight {
WeightCompactWidget(weight: weight.formatted(.number.precision(.fractionLength(1))), unit: "kg")
}
if let distance = node.latestEnvironmentMetrics?.distance {
DistanceCompactWidget(distance: distance.formatted(.number.precision(.fractionLength(0))), unit: "mm")
}
if let soilTemperature = node.latestEnvironmentMetrics?.soilTemperature {
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
let unit = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? "°F" : "°C"
SoilTemperatureCompactWidget(temperature: soilTemperature.localeTemperature().formatted(.number.precision(.fractionLength(0))), unit: unit)
}
if let soilMoisture = node.latestEnvironmentMetrics?.soilMoisture {
SoilMoistureCompactWidget(moisture: soilMoisture.formatted(.number.precision(.fractionLength(0))), unit: "%")
}
}
.padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical)
if !node.hasEnvironmentMetrics {
LocalWeatherConditions(location: node.latestPosition?.nodeLocation)
} else {
VStack {
if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 {
IndoorAirQuality(iaq: Int(node.latestEnvironmentMetrics?.iaq ?? 0), displayMode: .gradient)
.padding(.vertical)
}
LazyVGrid(columns: gridItemLayout) {
if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() {
WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP")
}
if let humidity = node.latestEnvironmentMetrics?.relativeHumidity {
if let temperature = node.latestEnvironmentMetrics?.temperature {
let dewPoint = calculateDewPoint(temp: temperature, relativeHumidity: humidity)
.formatted(.number.precision(.fractionLength(0))) + "°"
HumidityCompactWidget(humidity: Int(humidity), dewPoint: dewPoint)
} else {
HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil)
}
}
if let pressure = node.latestEnvironmentMetrics?.barometricPressure {
PressureCompactWidget(pressure: pressure.formatted(.number.precision(.fractionLength(2))), unit: "hPA", low: pressure <= 1009.144)
}
if let windSpeed = node.latestEnvironmentMetrics?.windSpeed {
let windSpeedMeasurement = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond)
let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) }
let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0))
WindCompactWidget(speed: windSpeedMeasurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))),
gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction)
}
if let rainfall1h = node.latestEnvironmentMetrics?.rainfall1H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall1h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall1H, rainfall: formattedRain, unit: unitLabel)
}
if let rainfall24h = node.latestEnvironmentMetrics?.rainfall24H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall24h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall24H, rainfall: formattedRain, unit: unitLabel)
}
if let radiation = node.latestEnvironmentMetrics?.radiation {
RadiationCompactWidget(radiation: radiation.formatted(.number.precision(.fractionLength(1))), unit: "µR/hr")
}
if let weight = node.latestEnvironmentMetrics?.weight {
WeightCompactWidget(weight: weight.formatted(.number.precision(.fractionLength(1))), unit: "kg")
}
if let distance = node.latestEnvironmentMetrics?.distance {
DistanceCompactWidget(distance: distance.formatted(.number.precision(.fractionLength(0))), unit: "mm")
}
if let soilTemperature = node.latestEnvironmentMetrics?.soilTemperature {
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
let unit = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? "°F" : "°C"
SoilTemperatureCompactWidget(temperature: soilTemperature.localeTemperature().formatted(.number.precision(.fractionLength(0))), unit: unit)
}
if let soilMoisture = node.latestEnvironmentMetrics?.soilMoisture {
SoilMoistureCompactWidget(moisture: soilMoisture.formatted(.number.precision(.fractionLength(0))), unit: "%")
}
}
.padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical)
}
}
// Apply accessibility properties to the environment section
.accessibilityElement(children: .combine)
}
}
if node.hasPowerMetrics && node.latestPowerMetrics != nil {
@ -316,7 +298,6 @@ struct NodeDetail: View {
PowerMetrics(metric: metric)
}
}
.accessibilityElement(children: .combine)
}
}
Section("Logs") {

View file

@ -31,7 +31,6 @@ struct NodeInfoItem: View {
.foregroundStyle(.gray)
.font(.callout)
}
.accessibilityElement(children: .combine)
Spacer()
}
VStack(alignment: .center) {
@ -50,11 +49,9 @@ struct NodeInfoItem: View {
.cornerRadius(5)
}
}
.accessibilityElement(children: .combine)
}
Spacer()
}
.accessibilityElement(children: .combine)
.onAppear {
Api().loadDeviceHardwareData { (hw) in
for device in hw {
@ -82,7 +79,6 @@ struct NodeInfoItem: View {
Text(String("incomplete".localized))
}
}
.accessibilityElement(children: .combine)
}
}
}

View file

@ -7,99 +7,9 @@
import SwiftUI
import CoreLocation
import Foundation
struct NodeListItem: View {
// Accessibility: Synthesized description for VoiceOver
private var accessibilityDescription: String {
var desc = ""
if let shortName = node.user?.shortName {
// Format the shortName using the String extension method
desc = shortName.formatNodeNameForVoiceOver()
} else if let longName = node.user?.longName {
desc = longName
} else {
desc = "unknown node"
}
if connected {
desc += ", currently connected"
}
if node.favorite {
desc += ", favorite"
}
if node.lastHeard != nil {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
let relative = formatter.localizedString(for: node.lastHeard!, relativeTo: Date())
desc += ", last heard " + relative
}
if node.isOnline {
desc += ", online"
} else {
desc += ", offline"
}
let role = DeviceRoles(rawValue: Int(node.user?.role ?? 0))
if let roleName = role?.name {
desc += ", role: \(roleName)"
}
if node.hopsAway > 0 {
desc += ", \(node.hopsAway) hops away"
}
if let battery = node.latestDeviceMetrics?.batteryLevel {
// Check for plugged in and charging states, same logic as in BatteryCompact and BatteryGauge
if battery > 100 {
desc += ", " + NSLocalizedString("device_plugged_in", comment: "VoiceOver value for plugged in device")
} else if battery == 100 {
desc += ", " + NSLocalizedString("device_charging", comment: "VoiceOver value for charging device")
} else {
desc += ", battery \(battery)%"
}
}
// Add distance and heading/bearing if available, but only for non-connected nodes
if !connected, let (lastPosition, myCoord) = locationData {
let nodeCoord = CLLocation(latitude: lastPosition.nodeCoordinate!.latitude, longitude: lastPosition.nodeCoordinate!.longitude)
let metersAway = nodeCoord.distance(from: myCoord)
// Distance information
let distanceFormatter = LengthFormatter()
distanceFormatter.unitStyle = .medium
let formattedDistance = distanceFormatter.string(fromMeters: metersAway)
// For VoiceOver, prepend 'Distance' (localized)
desc += ", " + String(format: "%@: %@", NSLocalizedString("distance", comment: "Distance label for VoiceOver"), formattedDistance)
// Add bearing/heading information for VoiceOver
let trueBearing = getBearingBetweenTwoPoints(point1: myCoord, point2: nodeCoord)
let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees)
let formattedHeading = heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0))))
// Using a direct format without requiring a new localization key
desc += ", " + NSLocalizedString("heading", comment: "Heading label for VoiceOver") + " " + formattedHeading
}
// Add signal strength if available
if node.snr != 0 && !node.viaMqtt {
let signalStrength: BLESignalStrength
if node.snr < -10 {
signalStrength = .weak
} else if node.snr < 5 {
signalStrength = .normal
} else {
signalStrength = .strong
}
let signalString: String
switch signalStrength {
case .weak:
signalString = NSLocalizedString("ble.signal.strength.weak", comment: "VoiceOver value for weak BLE signal strength")
case .normal:
signalString = NSLocalizedString("ble.signal.strength.normal", comment: "VoiceOver value for normal BLE signal strength")
case .strong:
signalString = NSLocalizedString("ble.signal.strength.strong", comment: "VoiceOver value for strong BLE signal strength")
}
desc += ", " + signalString
}
return desc
}
@ObservedObject var node: NodeInfoEntity
var connected: Bool
var connectedNode: Int64
@ -257,10 +167,7 @@ struct NodeListItem: View {
}
.padding(.top, 4)
.padding(.bottom, 4)
// Accessibility: Make the whole row a single element for VoiceOver
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
}
}
}
struct DefaultIcon: View {

View file

@ -235,8 +235,6 @@ struct NodeList: View {
phoneOnly: true
)
}
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
.accessibilityElement(children: .contain)
)
} content: {
if let node = selectedNode {
@ -255,7 +253,6 @@ struct NodeList: View {
} label: {
Image(systemName: "rectangle")
}
.accessibilityLabel("Hide sidebar")
}
ConnectedDevice(
bluetoothOn: bleManager.isSwitchedOn,
@ -264,8 +261,6 @@ struct NodeList: View {
phoneOnly: true
)
}
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
.accessibilityElement(children: .contain)
)
}
} else {