mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Additional accessibilityLabels for VoiceOver users (take #3)
This commit is contained in:
parent
f56f3723ee
commit
b0f1dbf355
13 changed files with 875 additions and 164 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
Image(systemName: "battery.100.bolt")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
} else if batteryLevel < 100 && batteryLevel > 74 {
|
||||
Image(systemName: "battery.75")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
} else if batteryLevel < 75 && batteryLevel > 49 {
|
||||
Image(systemName: "battery.50")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
} else if batteryLevel < 50 && batteryLevel > 14 {
|
||||
Image(systemName: "battery.25")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
} else if batteryLevel < 15 && batteryLevel > 0 {
|
||||
Image(systemName: "battery.0")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
} else if batteryLevel == 0 {
|
||||
Image(systemName: "battery.0")
|
||||
.font(iconFont)
|
||||
.foregroundColor(.red)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
} else 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)
|
||||
.accessibilityHidden(true)
|
||||
} else if batteryLevel > 74 {
|
||||
Image(systemName: "battery.75")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.accessibilityHidden(true)
|
||||
} else if batteryLevel > 49 {
|
||||
Image(systemName: "battery.50")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.accessibilityHidden(true)
|
||||
} else if batteryLevel > 14 {
|
||||
Image(systemName: "battery.25")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.accessibilityHidden(true)
|
||||
} else if batteryLevel > 0 {
|
||||
Image(systemName: "battery.0")
|
||||
.font(iconFont)
|
||||
.foregroundColor(color)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.accessibilityHidden(true)
|
||||
} else {
|
||||
Image(systemName: "battery.0")
|
||||
.font(iconFont)
|
||||
.foregroundColor(.red)
|
||||
.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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ struct IgnoreNodeButton: View {
|
|||
Image(systemName: node.ignored ? "minus.circle.fill" : "minus.circle")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
// Accessibility: Label for VoiceOver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue