diff --git a/Localizable.xcstrings b/Localizable.xcstrings index aa5cbd4c..3a479de4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -35342,7 +35342,485 @@ } } } + }, + "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" -} \ No newline at end of file +} diff --git a/Meshtastic/Extensions/String.swift b/Meshtastic/Extensions/String.swift index d2ae1e5a..6a57da9e 100644 --- a/Meshtastic/Extensions/String.swift +++ b/Meshtastic/Extensions/String.swift @@ -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 + } + // 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 { diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index c5d17f16..6b0287b0 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -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: 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 { - return Divided(amount: amount, shape: self) - } + func divided(amount: CGFloat) -> Divided { + 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 } diff --git a/Meshtastic/Views/Helpers/BatteryCompact.swift b/Meshtastic/Views/Helpers/BatteryCompact.swift index 4ac61d0c..bb9819a2 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -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") } } diff --git a/Meshtastic/Views/Helpers/BatteryGauge.swift b/Meshtastic/Views/Helpers/BatteryGauge.swift index 952c9768..81e81e7e 100644 --- a/Meshtastic/Views/Helpers/BatteryGauge.swift +++ b/Meshtastic/Views/Helpers/BatteryGauge.swift @@ -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 { @@ -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) } @@ -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 { diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index c795b1b0..4a46db41 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -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) } } } diff --git a/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift b/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift index 2f1634bc..fd166f51 100644 --- a/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift +++ b/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift @@ -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) diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift index aacbd60d..9839e246 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift @@ -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) diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index 84fdf4d3..2d73d5c0 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -40,6 +40,7 @@ struct IgnoreNodeButton: View { Image(systemName: node.ignored ? "minus.circle.fill" : "minus.circle") .symbolRenderingMode(.multicolor) } + // Accessibility: Label for VoiceOver } } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 081e7adc..c5670e06 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -46,7 +46,8 @@ struct NodeDetail: View { Section("Hardware") { NodeInfoItem(node: node) } - Section("Node") { + .accessibilityElement(children: .combine) + Section("Node") { // Node HStack(alignment: .center) { Spacer() CircleText( @@ -67,6 +68,7 @@ struct NodeDetail: View { .foregroundColor(getRssiColor(rssi: node.rssi)) .font(.caption) } + .accessibilityElement(children: .combine) } if node.telemetries?.count ?? 0 > 0 { Spacer() @@ -74,6 +76,7 @@ struct NodeDetail: View { } Spacer() } + .accessibilityElement(children: .combine) .listRowSeparator(.hidden) if let user = node.user { if !user.keyMatch { @@ -86,6 +89,7 @@ struct NodeDetail: View { .foregroundStyle(.secondary) .font(.callout) } + .accessibilityElement(children: .combine) } icon: { Image(systemName: "key.slash.fill") .symbolRenderingMode(.multicolor) @@ -104,6 +108,7 @@ struct NodeDetail: View { Text(String(node.num)) .textSelection(.enabled) } + .accessibilityElement(children: .combine) HStack { Label { @@ -116,6 +121,7 @@ struct NodeDetail: View { Text(node.num.toHex()) .textSelection(.enabled) } + .accessibilityElement(children: .combine) if let metadata = node.metadata { HStack { @@ -129,6 +135,7 @@ struct NodeDetail: View { Text(metadata.firmwareVersion ?? "Unknown".localized) } + .accessibilityElement(children: .combine) } if let role = node.user?.role, let deviceRole = DeviceRoles(rawValue: Int(role)) { @@ -142,6 +149,7 @@ 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 { @@ -161,6 +169,7 @@ 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())! { @@ -179,7 +188,9 @@ struct NodeDetail: View { Text(firstHeard.formatted()) .textSelection(.enabled) } - }.onTapGesture { + } + .accessibilityElement(children: .combine) + .onTapGesture { dateFormatRelative.toggle() } } @@ -203,7 +214,9 @@ struct NodeDetail: View { Text(lastHeard.formatted()) .textSelection(.enabled) } - }.onTapGesture { + } + .accessibilityElement(children: .combine) + .onTapGesture { dateFormatRelative.toggle() } } @@ -216,79 +229,84 @@ struct NodeDetail: View { if node.hasPositions && UserDefaults.environmentEnableWeatherKit || node.hasDataForLatestEnvironmentMetrics(attributes: ["iaq", "temperature", "relativeHumidity", "barometricPressure", "windSpeed", "radiation", "weight", "Distance", "soilTemperature", "soilMoisture"]) { Section("Environment") { - 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") + // 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) } - 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) + 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: "%") } } - 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) } - .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) } } + // Apply accessibility properties to the environment section + .accessibilityElement(children: .combine) } } if node.hasPowerMetrics && node.latestPowerMetrics != nil { @@ -298,6 +316,7 @@ struct NodeDetail: View { PowerMetrics(metric: metric) } } + .accessibilityElement(children: .combine) } } Section("Logs") { diff --git a/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift index 07f3d92c..eb4c37b0 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift @@ -31,6 +31,7 @@ struct NodeInfoItem: View { .foregroundStyle(.gray) .font(.callout) } + .accessibilityElement(children: .combine) Spacer() } VStack(alignment: .center) { @@ -49,9 +50,11 @@ struct NodeInfoItem: View { .cornerRadius(5) } } + .accessibilityElement(children: .combine) } Spacer() } + .accessibilityElement(children: .combine) .onAppear { Api().loadDeviceHardwareData { (hw) in for device in hw { @@ -79,6 +82,7 @@ struct NodeInfoItem: View { Text(String("incomplete".localized)) } } + .accessibilityElement(children: .combine) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index 2978ceab..62dd5fd0 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -7,9 +7,99 @@ 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 @@ -167,7 +257,10 @@ 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 { diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 1e823020..6b305148 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -243,6 +243,8 @@ struct NodeList: View { phoneOnly: true ) } + // Make sure the ZStack passes through accessibility to the ConnectedDevice component + .accessibilityElement(children: .contain) ) } content: { if let node = selectedNode { @@ -261,6 +263,7 @@ struct NodeList: View { } label: { Image(systemName: "rectangle") } + .accessibilityLabel("Hide sidebar") } ConnectedDevice( bluetoothOn: bleManager.isSwitchedOn, @@ -269,6 +272,8 @@ struct NodeList: View { phoneOnly: true ) } + // Make sure the ZStack passes through accessibility to the ConnectedDevice component + .accessibilityElement(children: .contain) ) } } else {