From 531e74dd5cf921f7debc8aa4d46a832b114a53e3 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 6 May 2025 21:49:44 -0700 Subject: [PATCH 01/16] Fixed waypoint updating logic and waypoint notification --- Meshtastic/Helpers/MeshPackets.swift | 88 ++++++++++++++++++---------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index bcc4659b..e25ae8c1 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1035,22 +1035,33 @@ func textMessageAppPacket( } } -func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { - +func waypointPacket(packet: MeshPacket, context: NSManagedObjectContext) { let logString = String.localizedStringWithFormat("mesh.log.waypoint.received %@".localized, String(packet.from)) Logger.mesh.info("📍 \(logString, privacy: .public)") - let fetchWaypointRequest = WaypointEntity.fetchRequest() - fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(packet.id)) - do { - if let waypointMessage = try? Waypoint(serializedBytes: packet.decoded.payload) { - let fetchedWaypoint = try context.fetch(fetchWaypointRequest) - if fetchedWaypoint.isEmpty { - let waypoint = WaypointEntity(context: context) + // Fetch waypoint by waypointMessage.id, not packet.id + let fetchWaypointRequest = WaypointEntity.fetchRequest() + fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(waypointMessage.id)) - waypoint.id = Int64(packet.id) + let fetchedWaypoint = try context.fetch(fetchWaypointRequest) + // Fetch the node info to get the short name + var nodeShortName: String = "?" + let fetchNodeRequest = NodeInfoEntity.fetchRequest() + fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + do { + let fetchedNode = try context.fetch(fetchNodeRequest) + if let node = fetchedNode.first, let user = node.user { + nodeShortName = user.shortName ?? node.user?.userId ?? String(packet.from.toHex()) + } + } catch { + Logger.data.error("Failed to fetch NodeInfoEntity for node \(packet.from.toHex(), privacy: .public): \(error)") + } + if fetchedWaypoint.isEmpty { + // Create a new waypoint + let waypoint = WaypointEntity(context: context) + waypoint.id = Int64(waypointMessage.id) // Use waypointMessage.id waypoint.name = waypointMessage.name waypoint.longDescription = waypointMessage.description_p waypoint.latitudeI = waypointMessage.latitudeI @@ -1073,7 +1084,7 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { manager.notifications = [ Notification( id: ("notification.id.\(waypoint.id)"), - title: "New Waypoint Received", + title: "New Waypoint From \(nodeShortName)", subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")", content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")", target: "map", @@ -1088,26 +1099,41 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") } } else { - fetchedWaypoint[0].id = Int64(packet.id) - fetchedWaypoint[0].name = waypointMessage.name - fetchedWaypoint[0].longDescription = waypointMessage.description_p - fetchedWaypoint[0].latitudeI = waypointMessage.latitudeI - fetchedWaypoint[0].longitudeI = waypointMessage.longitudeI - fetchedWaypoint[0].icon = Int64(waypointMessage.icon) - fetchedWaypoint[0].locked = Int64(waypointMessage.lockedTo) - if waypointMessage.expire >= 1 { - fetchedWaypoint[0].expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) - } else { - fetchedWaypoint[0].expire = nil - } - fetchedWaypoint[0].lastUpdated = Date() - do { - try context.save() - Logger.data.info("💾 Updated Node Waypoint App Packet For: \(fetchedWaypoint[0].id, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + // Update existing waypoint + let existingWaypoint = fetchedWaypoint[0] + if existingWaypoint.locked == 0 || existingWaypoint.locked == packet.from { + let currentTime = Int64(Date().timeIntervalSince1970) + if waypointMessage.expire > 0 && waypointMessage.expire <= currentTime { + BLEManager.shared.context.delete(existingWaypoint) + do { + try context.save() + Logger.data.info("💾 Deleted a waypoint") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } + } + existingWaypoint.name = waypointMessage.name + existingWaypoint.longDescription = waypointMessage.description_p + existingWaypoint.latitudeI = waypointMessage.latitudeI + existingWaypoint.longitudeI = waypointMessage.longitudeI + existingWaypoint.icon = Int64(waypointMessage.icon) + existingWaypoint.locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire >= 1 { + existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } else { + existingWaypoint.expire = nil + } + existingWaypoint.lastUpdated = Date() + do { + try context.save() + Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } } } } From a1d5a8a0d04c62a71e10d12761e065281ad3c3b1 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 6 May 2025 22:03:24 -0700 Subject: [PATCH 02/16] fixed a small issue where it tries updating it after its deleted --- Meshtastic/Helpers/MeshPackets.swift | 41 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index e25ae8c1..870c13cb 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1104,7 +1104,7 @@ func waypointPacket(packet: MeshPacket, context: NSManagedObjectContext) { if existingWaypoint.locked == 0 || existingWaypoint.locked == packet.from { let currentTime = Int64(Date().timeIntervalSince1970) if waypointMessage.expire > 0 && waypointMessage.expire <= currentTime { - BLEManager.shared.context.delete(existingWaypoint) + context.delete(existingWaypoint) do { try context.save() Logger.data.info("💾 Deleted a waypoint") @@ -1113,26 +1113,27 @@ func waypointPacket(packet: MeshPacket, context: NSManagedObjectContext) { let nsError = error as NSError Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") } - } - existingWaypoint.name = waypointMessage.name - existingWaypoint.longDescription = waypointMessage.description_p - existingWaypoint.latitudeI = waypointMessage.latitudeI - existingWaypoint.longitudeI = waypointMessage.longitudeI - existingWaypoint.icon = Int64(waypointMessage.icon) - existingWaypoint.locked = Int64(waypointMessage.lockedTo) - if waypointMessage.expire >= 1 { - existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) } else { - existingWaypoint.expire = nil - } - existingWaypoint.lastUpdated = Date() - do { - try context.save() - Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + existingWaypoint.name = waypointMessage.name + existingWaypoint.longDescription = waypointMessage.description_p + existingWaypoint.latitudeI = waypointMessage.latitudeI + existingWaypoint.longitudeI = waypointMessage.longitudeI + existingWaypoint.icon = Int64(waypointMessage.icon) + existingWaypoint.locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire >= 1 { + existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } else { + existingWaypoint.expire = nil + } + existingWaypoint.lastUpdated = Date() + do { + try context.save() + Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } } } } From 1a2429f36f1fac66e4c2c4a6b5f780b2c597ec77 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Wed, 7 May 2025 19:43:47 -0700 Subject: [PATCH 03/16] Add fallback to lastKnown location to fix the "AppleParkBug" --- Meshtastic/Helpers/LocationsHandler.swift | 35 +++++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index 75830805..f7166f70 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -8,6 +8,7 @@ import SwiftUI import CoreLocation import OSLog +import CoreData // Shared state that manages the `CLLocationManager` and `CLBackgroundActivitySession`. @MainActor class LocationsHandler: ObservableObject { @@ -109,15 +110,43 @@ import OSLog } else { locationsArray = [location] } + UserDefaults.standard.set(location.coordinate.latitude, forKey: "lastKnownLatitude") + UserDefaults.standard.set(location.coordinate.longitude, forKey: "lastKnownLongitude") + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastKnownLocationTimestamp") return true } - static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090) static var currentLocation: CLLocationCoordinate2D { - guard let location = shared.manager.location else { + if let location = shared.manager.location { + return location.coordinate + } else { + // Check authorization status + let status = shared.manager.authorizationStatus + switch status { + case .notDetermined: + Logger.services.info("📍 [App] Location permission not determined, requesting authorization") + shared.manager.requestWhenInUseAuthorization() + case .denied, .restricted: + Logger.services.warning("📍 [App] Location access denied or restricted. Please enable location services in Settings to get accurate positioning!") + shared.manager.requestWhenInUseAuthorization() + default: + break + } + // Fallback 1: Last known location from UserDefaults (if within 4 hours) + if let lat = UserDefaults.standard.object(forKey: "lastKnownLatitude") as? Double, + let lon = UserDefaults.standard.object(forKey: "lastKnownLongitude") as? Double, + let timestamp = UserDefaults.standard.object(forKey: "lastKnownLocationTimestamp") as? Double, + lat >= -90 && lat <= 90, + lon >= -180 && lon <= 180, + Date().timeIntervalSince1970 - timestamp <= 14_400 { // 4 hours in seconds + Logger.services.info("📍 [App] Falling back to last known location (age: \(Int(Date().timeIntervalSince1970 - timestamp)) seconds)") + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + // Fallback 2: Default location + Logger.services.warning("📍 [App] No Location and no last known location, something is really wrong. Teleporting user to Apple Park") return DefaultLocation } - return location.coordinate } static var satsInView: Int { From dcfad05599770b02e7516abed39fd2c04d40e08b Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 9 May 2025 16:46:08 -0400 Subject: [PATCH 04/16] Rate limited traceroute btn and progress indicator --- Localizable.xcstrings | 3 + Meshtastic.xcodeproj/project.pbxproj | 4 + .../Contents.json | 12 ++ .../progress.ring.dashed.svg | 169 ++++++++++++++++++ .../Views/Helpers/RateLimitedButton.swift | 113 ++++++++++++ .../Helpers/Actions/TraceRouteButton.swift | 26 ++- Meshtastic/Views/Nodes/NodeList.swift | 19 +- 7 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json create mode 100644 Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg create mode 100644 Meshtastic/Views/Helpers/RateLimitedButton.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 95f524d2..8b8f65a2 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -35986,6 +35986,9 @@ } } } + }, + "Trace Route (in %@s)" : { + }, "Trace Route Log" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 39426e2f..96995a31 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; }; + 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -294,6 +295,7 @@ 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = ""; }; + 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -1041,6 +1043,7 @@ DD5E523D298F5A7D00D21B61 /* Weather */, DD6F65712C6AB8EC0053C113 /* SecureInput.swift */, 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */, + 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */, ); path = Helpers; sourceTree = ""; @@ -1412,6 +1415,7 @@ DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */, + 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */, DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */, diff --git a/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json new file mode 100644 index 00000000..7c46aedd --- /dev/null +++ b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "progress.ring.dashed.svg", + "idiom" : "universal" + } + ] +} diff --git a/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg new file mode 100644 index 00000000..5d91388c --- /dev/null +++ b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg @@ -0,0 +1,169 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from progress.ring.dashed + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Views/Helpers/RateLimitedButton.swift b/Meshtastic/Views/Helpers/RateLimitedButton.swift new file mode 100644 index 00000000..30c5d667 --- /dev/null +++ b/Meshtastic/Views/Helpers/RateLimitedButton.swift @@ -0,0 +1,113 @@ +// +// RateLimitCountdownView.swift +// Meshtastic +// +// Created by Jake Bordens on 5/5/25. +// + +import SwiftUI + +// This class provides a rate limited button. +// Provide a key to differentiate which action is rate-limited +// This allows you to keep different rate limits for different action +// Rate limits are stored in a RateLimitStorage singleton, but do not persist +public struct RateLimitedButton: View { + typealias Builder = ((percentComplete: Double, secondsRemaining: TimeInterval)?) -> Content + + let key: String + + @StateObject var storage = RateLimitStorage.shared + + let rateLimit: TimeInterval + let content: Builder + let action: () -> Void + + init(key: String, rateLimit: TimeInterval, action: @escaping () -> Void, @ViewBuilder label: @escaping Builder) { + self.key = key + self.rateLimit = rateLimit + self.content = label + self.action = action + } + + public var body: some View { + let percentRemaining = storage.rateLimitRemainingPercentage(forKey: key) + let secondsRemaining = storage.rateLimitSecondsRemaining(forKey: key) + if percentRemaining > 0.0 { + content((percentRemaining, secondsRemaining)) + } else { + Button { + storage.actionOccured(forKey: key, rateLimit: rateLimit) + action() + } label: { + content(nil) + } + } + } +} + +// To store the time an action occured (name by a key) and the time limit +// Does not persist across app launches +class RateLimitStorage: ObservableObject { + private struct RateLimiter { + var actionOccuredTimestamp: Date + var rateLimitSeconds: TimeInterval + + var rateLimitExpires: Date { + return actionOccuredTimestamp.addingTimeInterval(rateLimitSeconds) + } + } + + static var shared: RateLimitStorage = RateLimitStorage() // Singleton instance + + private var rateLimits = [String: RateLimiter]() + private var timer: Timer? + + func actionOccured(forKey key: String, rateLimit: TimeInterval) { + let now = Date() + if let existingRateLimit = rateLimits[key] { + if existingRateLimit.rateLimitExpires > now.addingTimeInterval(rateLimit) { + // We have an existing rate limit that is larger than the one being requested + // Ignore + return + } + } + self.objectWillChange.send() + rateLimits[key] = RateLimiter(actionOccuredTimestamp: now, rateLimitSeconds: rateLimit) + startTimerIfNecessary() + } + + func rateLimitRemainingPercentage(forKey: String) -> Double { + guard let rateLimit = rateLimits[forKey] else { + return 0.0 + } + let percent = (rateLimit.rateLimitExpires.timeIntervalSinceNow) / rateLimit.rateLimitSeconds + return min(1.0, max(percent, 0.0)) + } + + func rateLimitSecondsRemaining(forKey: String) -> TimeInterval { + guard let rateLimit = rateLimits[forKey] else { + return 0.0 + } + return rateLimit.rateLimitExpires.timeIntervalSinceNow + } + + func startTimerIfNecessary() { + // Timer exists, don't create one + guard timer == nil else { return } + + // Create the timer + self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.objectWillChange.send() + + // Determine if we can clean up the dictionary and stop the timer. + let maxExpiration = self.rateLimits.values.map { $0.rateLimitExpires }.max() ?? .distantPast + if maxExpiration.timeIntervalSinceNow < 0 { + // All rateLimits are in the past. Stop and clean up + self.timer?.invalidate() + self.timer = nil + self.rateLimits.removeAll() + } + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift index 64e2563a..fe5d8c87 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift @@ -9,18 +9,28 @@ struct TraceRouteButton: View { private var isPresentingTraceRouteSentAlert: Bool = false var body: some View { - Button { + RateLimitedButton(key: "traceroute", rateLimit: 30.0) { isPresentingTraceRouteSentAlert = bleManager.sendTraceRouteRequest( destNum: node.user?.num ?? 0, wantResponse: true ) - } label: { - Label { - Text("Trace Route") - } icon: { - Image(systemName: "signpost.right.and.left") - .symbolRenderingMode(.hierarchical) - } + } label: { completion in + if let completion, completion.percentComplete > 0.0 { + Label { + Text("Trace Route (in \(completion.secondsRemaining.formatted(.number.precision(.fractionLength(0))))s)") + .foregroundStyle(.secondary) + } icon: { + Image("progress.ring.dashed", variableValue: completion.percentComplete) + .foregroundStyle(.secondary) + }.disabled(true) + } else { + Label { + Text("Trace Route") + } icon: { + Image(systemName: "signpost.right.and.left") + .symbolRenderingMode(.hierarchical) + } + } }.alert( "Trace Route Sent", isPresented: $isPresentingTraceRouteSentAlert diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index b4713dbd..44f968be 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -91,21 +91,10 @@ struct NodeList: View { Label("Message", systemImage: "message") } } - Button { - let traceRouteSent = bleManager.sendTraceRouteRequest( - destNum: node.num, - wantResponse: true - ) - if traceRouteSent { - isPresentingTraceRouteSentAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingTraceRouteSentAlert = false - } - } - - } label: { - Label("Trace Route", systemImage: "signpost.right.and.left") - } + TraceRouteButton( + bleManager: bleManager, + node: node + ) Button { let positionSent = bleManager.sendPosition( channel: node.channel, From b855ecc71332091bbc13f5868df58dedff7686f9 Mon Sep 17 00:00:00 2001 From: git bisector Date: Tue, 6 May 2025 19:36:42 -0700 Subject: [PATCH 05/16] Additional accessibilityLabels for VoiceOver users. --- Localizable.xcstrings | 480 +++++++++++++++++- Meshtastic/Extensions/String.swift | 11 + Meshtastic/Views/Bluetooth/Connect.swift | 30 ++ .../Helpers/BLESignalStrengthIndicator.swift | 82 +-- Meshtastic/Views/Helpers/BatteryCompact.swift | 115 +++-- Meshtastic/Views/Helpers/BatteryGauge.swift | 35 +- .../Views/Helpers/ConnectedDevice.swift | 50 +- .../RequestPositionButton.swift | 1 + .../TextMessageField/TextMessageSize.swift | 2 + .../Helpers/Actions/IgnoreNodeButton.swift | 1 + .../Views/Nodes/Helpers/NodeDetail.swift | 159 +++--- .../Views/Nodes/Helpers/NodeInfoItem.swift | 4 + .../Views/Nodes/Helpers/NodeListItem.swift | 95 +++- Meshtastic/Views/Nodes/NodeList.swift | 5 + 14 files changed, 906 insertions(+), 164 deletions(-) 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/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 5e9dd834..32d445bb 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -12,6 +12,7 @@ import CoreLocation import CoreBluetooth import OSLog import TipKit +import Foundation #if canImport(ActivityKit) import ActivityKit #endif @@ -27,6 +28,31 @@ 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 @@ -86,6 +112,8 @@ struct Connect: View { } } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(nodeAccessibilityLabel()) .font(.caption) .foregroundColor(Color.gray) .padding([.top]) @@ -299,6 +327,8 @@ 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) { diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index c5d17f16..73d38f98 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -32,47 +32,65 @@ 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) + 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 + } + } } 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 { - 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") } } 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 { From 7063e7d419d556d1bd9b374688f6719bc7883253 Mon Sep 17 00:00:00 2001 From: git bisector Date: Fri, 9 May 2025 19:20:27 -0700 Subject: [PATCH 06/16] Fix TraceRoute notification navigation to correct node (#1115) When clicking on a completed TraceRoute notification, the app now navigates to the correct destination node instead of the connected node. This fixes issue #1115 where the app was navigating to the wrong node detail screen. --- Meshtastic/Helpers/BLEManager.swift | 2 +- Meshtastic/MeshtasticAppDelegate.swift | 17 ++++++++++++++- Meshtastic/Views/Nodes/NodeList.swift | 30 +++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index ba65f66a..b5971896 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -940,7 +940,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate subtitle: "TR received back from \(destinationHop.name ?? "unknown")", content: "Hops from: \(tr.hopsTowards), Hops back: \(tr.hopsBack)\n\(tr.routeText ?? "Unknown".localized)\n\(tr.routeBackText ?? "Unknown".localized)", target: "nodes", - path: "meshtastic:///nodes?nodenum=\(connectedNode.user?.num ?? 0)" + path: "meshtastic:///nodes?nodenum=\(tr.node?.num ?? 0)" ) ] manager.schedule() diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 365faef4..f4c405bb 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -97,7 +97,22 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat if let targetValue = userInfo["target"] as? String, let deepLink = userInfo["path"] as? String, let url = URL(string: deepLink) { - Logger.services.info("userNotificationCenter didReceiveResponse \(targetValue, privacy: .public) \(deepLink, privacy: .public)") + Logger.services.info("userNotificationCenter didReceiveResponse handling deeplink: \(targetValue, privacy: .public) \(deepLink, privacy: .public)") + // Handle TraceRoute notifications specially to ensure they navigate correctly + if deepLink.contains("meshtastic:///nodes") && deepLink.contains("nodenum=") { + // First extract the node number from the URL + if let nodeNumString = deepLink.components(separatedBy: "nodenum=").last, + let nodeNum = Int64(nodeNumString) { + Logger.services.info("Navigation to specific node via notification: \(nodeNum, privacy: .public)") + self.router?.navigationState.selectedTab = .nodes + // Post a notification to trigger app-wide refresh + NotificationCenter.default.post(name: NSNotification.Name("ForceNavigationRefresh"), + object: nil, + userInfo: ["nodeNum": nodeNum]) + self.router?.navigationState.nodeListSelectedNodeNum = nodeNum + } + } + // Still call the regular router in all cases router?.route(url: url) } else { Logger.services.error("Failed to handle notification response: \(userInfo, privacy: .public)") diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 1e823020..5a531ec2 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -28,6 +28,8 @@ struct NodeList: View { @State private var isFavorite = false @State private var isIgnored = false @State private var isEnvironment = false + // Force refresh ID to make SwiftUI rebuild the view hierarchy + @State private var forceRefreshID = UUID() @State private var distanceFilter = false @State private var maxDistance: Double = 800000 @State private var hopsAway: Double = -1.0 @@ -142,6 +144,7 @@ struct NodeList: View { } var body: some View { + // Use forceRefreshID to completely rebuild the view when notifications update the selected node NavigationSplitView(columnVisibility: $columnVisibility) { List(nodes, id: \.self, selection: $selectedNode) { node in NodeListItem( @@ -326,16 +329,41 @@ struct NodeList: View { } .onChange(of: router.navigationState) { if let selected = router.navigationState.nodeListSelectedNodeNum { - self.selectedNode = getNodeInfo(id: selected, context: context) + // Force a complete view rebuild by generating a new UUID + Logger.services.info("Forcing view rebuild with new ID: \(self.forceRefreshID)") + // First clear selection + self.forceRefreshID = UUID() + self.selectedNode = nil + // Then after a short delay, set the new selection. Makes it obvious to use page is refreshing too. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + // Generate another UUID to ensure view gets rebuilt + self.forceRefreshID = UUID() + self.selectedNode = getNodeInfo(id: selected, context: context) + Logger.services.info("Complete view refresh with node: \(selected, privacy: .public)") + } } else { self.selectedNode = nil } } .onAppear { + // Set up notification observer for forced refreshes from notifications + NotificationCenter.default.addObserver(forName: NSNotification.Name("ForceNavigationRefresh"), object: nil, queue: .main) { notification in + if let nodeNum = notification.userInfo?["nodeNum"] as? Int64 { + // Force complete refresh of view + self.forceRefreshID = UUID() + self.selectedNode = getNodeInfo(id: nodeNum, context: self.context) + Logger.services.info("NodeList directly updated from notification for node: \(nodeNum, privacy: .public)") + } + } + Task { await searchNodeList() } } + .onDisappear { + // Remove observer when view disappears + NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil) + } } private func searchNodeList() async { From 37828fb62f0692730e312e0a19e4511031b2a4b9 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 10 May 2025 08:46:28 -0700 Subject: [PATCH 07/16] Bump Version --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 403492d2..3021f949 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1785,7 +1785,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1818,7 +1818,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1849,7 +1849,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1881,7 +1881,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 07358b18e24bceff8cb789b3a72d02c057ee0748 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 10 May 2025 18:32:31 -0700 Subject: [PATCH 08/16] Revert "Additional accessibilityLabels for VoiceOver users." --- Localizable.xcstrings | 480 +----------------- Meshtastic/Extensions/String.swift | 11 - Meshtastic/Views/Bluetooth/Connect.swift | 30 -- .../Helpers/BLESignalStrengthIndicator.swift | 82 ++- Meshtastic/Views/Helpers/BatteryCompact.swift | 89 +--- Meshtastic/Views/Helpers/BatteryGauge.swift | 35 +- .../Views/Helpers/ConnectedDevice.swift | 50 +- .../RequestPositionButton.swift | 1 - .../TextMessageField/TextMessageSize.swift | 2 - .../Helpers/Actions/IgnoreNodeButton.swift | 1 - .../Views/Nodes/Helpers/NodeDetail.swift | 165 +++--- .../Views/Nodes/Helpers/NodeInfoItem.swift | 4 - .../Views/Nodes/Helpers/NodeListItem.swift | 95 +--- Meshtastic/Views/Nodes/NodeList.swift | 5 - 14 files changed, 154 insertions(+), 896 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 0fabb557..c4c9d2ca 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -35345,485 +35345,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" -} +} \ No newline at end of file diff --git a/Meshtastic/Extensions/String.swift b/Meshtastic/Extensions/String.swift index 6a57da9e..d2ae1e5a 100644 --- a/Meshtastic/Extensions/String.swift +++ b/Meshtastic/Extensions/String.swift @@ -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 { diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 32d445bb..5e9dd834 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -12,7 +12,6 @@ import CoreLocation import CoreBluetooth import OSLog import TipKit -import Foundation #if canImport(ActivityKit) import ActivityKit #endif @@ -28,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 @@ -112,8 +86,6 @@ struct Connect: View { } } } - .accessibilityElement(children: .ignore) - .accessibilityLabel(nodeAccessibilityLabel()) .font(.caption) .foregroundColor(Color.gray) .padding([.top]) @@ -327,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) { diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index 73d38f98..c5d17f16 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -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: 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 bb9819a2..4ac61d0c 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -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") } } diff --git a/Meshtastic/Views/Helpers/BatteryGauge.swift b/Meshtastic/Views/Helpers/BatteryGauge.swift index 81e81e7e..952c9768 100644 --- a/Meshtastic/Views/Helpers/BatteryGauge.swift +++ b/Meshtastic/Views/Helpers/BatteryGauge.swift @@ -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 { diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index 4a46db41..c795b1b0 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -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) } } } diff --git a/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift b/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift index fd166f51..2f1634bc 100644 --- a/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift +++ b/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift @@ -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) diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift index 9839e246..aacbd60d 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift @@ -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) diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index 2d73d5c0..84fdf4d3 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -40,7 +40,6 @@ 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 c5670e06..081e7adc 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -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") { diff --git a/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift index eb4c37b0..07f3d92c 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift @@ -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) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index 62dd5fd0..2978ceab 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -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 { diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 9af36fbd..a17a19d0 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -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 { From 916279b0d1bd8dba4783670384342160cf3f2b05 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 11 May 2025 00:00:54 -0700 Subject: [PATCH 09/16] Timestamps above messages spaced by 15 min --- .../Extensions/CoreData/MessageEntityExtension.swift | 7 +++++++ Meshtastic/Views/Messages/ChannelMessageList.swift | 10 ++++++++-- Meshtastic/Views/Messages/UserMessageList.swift | 9 ++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index 70f36c3d..95071cca 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -31,4 +31,11 @@ extension MessageEntity { return (try? context.fetch(fetchRequest)) ?? [MessageEntity]() } + + func displayTimestamp(aboveMessage: MessageEntity?) -> Bool { + if let aboveMessage = aboveMessage { + return aboveMessage.timestamp.addingTimeInterval(900) < timestamp // 15 seconds + } + return false // First message will have no timestamp + } } diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 6093d9eb..458d20ed 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -33,8 +33,15 @@ struct ChannelMessageList: View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack { - ForEach(channel.allPrivateMessages) { (message: MessageEntity) in + ForEach(Array(channel.allPrivateMessages.enumerated()), id: \.element.id) { index, message in + // Get the previous message, if it exists + let previousMessage = index > 0 ? channel.allPrivateMessages[index - 1] : nil let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) + if message.displayTimestamp(aboveMessage: previousMessage) { + Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.gray) + } if message.replyID > 0 { let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID }) HStack { @@ -44,7 +51,6 @@ struct ChannelMessageList: View { messageToHighlight = messageNum } scrollView.scrollTo(messageNum, anchor: .center) - // Reset highlight after delay Task { try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 686decaf..54ff0b4d 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -33,7 +33,14 @@ struct UserMessageList: View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack { - ForEach( user.messageList ) { (message: MessageEntity) in + ForEach( Array(user.messageList.enumerated()) , id: \.element.id) { index, message in + // Get the previous message, if it exists + let previousMessage = index > 0 ? user.messageList[index - 1] : nil + if message.displayTimestamp(aboveMessage: previousMessage) { + Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.gray) + } if user.num != bleManager.connectedPeripheral?.num ?? -1 { let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) From 5cccdcea22d3af46d4794a2bc7c7d2676d11f7af Mon Sep 17 00:00:00 2001 From: gitbisector Date: Sun, 11 May 2025 16:12:34 -0700 Subject: [PATCH 10/16] Additional accessibilityLabels for VoiceOver users (take #2) --- Localizable.xcstrings | 480 +++++++++++++++++- Meshtastic/Extensions/String.swift | 11 + Meshtastic/Views/Bluetooth/Connect.swift | 29 ++ .../Helpers/BLESignalStrengthIndicator.swift | 82 +-- Meshtastic/Views/Helpers/BatteryCompact.swift | 115 +++-- Meshtastic/Views/Helpers/BatteryGauge.swift | 35 +- .../Views/Helpers/ConnectedDevice.swift | 50 +- .../RequestPositionButton.swift | 1 + .../TextMessageField/TextMessageSize.swift | 2 + .../Helpers/Actions/IgnoreNodeButton.swift | 1 + .../Views/Nodes/Helpers/NodeDetail.swift | 159 +++--- .../Views/Nodes/Helpers/NodeInfoItem.swift | 4 + .../Views/Nodes/Helpers/NodeListItem.swift | 95 +++- Meshtastic/Views/Nodes/NodeList.swift | 5 + 14 files changed, 905 insertions(+), 164 deletions(-) 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/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 5e9dd834..60bb2072 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -27,6 +27,31 @@ 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 @@ -86,6 +111,8 @@ struct Connect: View { } } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(nodeAccessibilityLabel()) .font(.caption) .foregroundColor(Color.gray) .padding([.top]) @@ -299,6 +326,8 @@ 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) { diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index c5d17f16..73d38f98 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -32,47 +32,65 @@ 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) + 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 + } + } } 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 { - 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") } } 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 { From 670ad44a1d641c3f872ad67eb89cef3194c1168b Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 11 May 2025 18:56:41 -0700 Subject: [PATCH 11/16] fix comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic/Extensions/CoreData/MessageEntityExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index 95071cca..0cfbd579 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -34,7 +34,7 @@ extension MessageEntity { func displayTimestamp(aboveMessage: MessageEntity?) -> Bool { if let aboveMessage = aboveMessage { - return aboveMessage.timestamp.addingTimeInterval(900) < timestamp // 15 seconds + return aboveMessage.timestamp.addingTimeInterval(900) < timestamp // 15 minutes } return false // First message will have no timestamp } From 69e7a8ce4c841c0f605b444bbd3665d6f5876ea1 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 13 May 2025 06:19:27 -0700 Subject: [PATCH 12/16] Remove legacy admin --- Localizable.xcstrings | 1 + Meshtastic/Views/Settings/Config/BluetoothConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/DeviceConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/DisplayConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/LoRaConfig.swift | 8 +++++--- .../Settings/Config/Module/AmbientLightingConfig.swift | 3 +-- .../Settings/Config/Module/CannedMessagesConfig.swift | 3 +-- .../Settings/Config/Module/DetectionSensorConfig.swift | 3 +-- .../Config/Module/ExternalNotificationConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift | 3 +-- .../Views/Settings/Config/Module/PaxCounterConfig.swift | 3 +-- .../Views/Settings/Config/Module/RangeTestConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift | 3 +-- .../Views/Settings/Config/Module/SerialConfig.swift | 3 +-- .../Views/Settings/Config/Module/StoreForwardConfig.swift | 3 +-- .../Views/Settings/Config/Module/TelemetryConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/NetworkConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/PositionConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/PowerConfig.swift | 3 +-- Meshtastic/Views/Settings/Config/SecurityConfig.swift | 3 +-- 20 files changed, 24 insertions(+), 39 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index c4c9d2ca..c62f4e6a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -22468,6 +22468,7 @@ } }, "Positon config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index 82b4f628..81b43499 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -115,8 +115,7 @@ struct BluetoothConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty bluetooth config") - _ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 6539848a..cd812e5d 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -273,8 +273,7 @@ struct DeviceConfig: View { } else { if node.deviceConfig == nil { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty device config") - _ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index d6f14135..c9029408 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -178,8 +178,7 @@ struct DisplayConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty display config") - _ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index 196f6ffb..1aae6ea4 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -248,13 +248,15 @@ struct LoRaConfig: View { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.loRaConfig == nil { + Logger.mesh.info("⚙️ Empty or expired lora config requesting via PKI admin") - _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + if connectedNode.user != nil && node.user != nil { + _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty lora config") - _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index 24c0ada3..efbeed70 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -100,8 +100,7 @@ struct AmbientLightingConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty ambient lighting module config") - _ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 20a62136..0fbcbcf8 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -248,8 +248,7 @@ struct CannedMessagesConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty canned messages module config") - _ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index e042ca77..94b96b5d 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -206,8 +206,7 @@ struct DetectionSensorConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty detection sensor module config") - _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index e9959b41..08745f04 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -214,8 +214,7 @@ struct ExternalNotificationConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty external notificaiton module config") - _ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 6967a2ab..bd2dfeeb 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -365,8 +365,7 @@ struct MQTTConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty mqtt module config") - _ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index 6d71f7a7..7c84b406 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -73,8 +73,7 @@ struct PaxCounterConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty pax counter module config") - _ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index cc323170..b2636967 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -96,8 +96,7 @@ struct RangeTestConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty range test module config") - _ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index 71452615..da30e1e4 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -87,8 +87,7 @@ struct RtttlConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty ringtone module config") - _ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index 0bbe9fbc..d1aca540 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -151,8 +151,7 @@ struct SerialConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty serial module config") - _ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index e9fc429f..c49fadf1 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -152,8 +152,7 @@ struct StoreForwardConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty store & forward module config") - _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index 0dfe7566..0ee48f86 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -149,8 +149,7 @@ struct TelemetryConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty telemetry module config") - _ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 35e25660..c4dcb8f2 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -158,8 +158,7 @@ struct NetworkConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty network config") - _ = bleManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 8fbd9d14..3af2546d 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -416,8 +416,7 @@ struct PositionConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty position config") - _ = bleManager.requestPositionConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/PowerConfig.swift b/Meshtastic/Views/Settings/Config/PowerConfig.swift index e3c26ffb..9ba385ff 100644 --- a/Meshtastic/Views/Settings/Config/PowerConfig.swift +++ b/Meshtastic/Views/Settings/Config/PowerConfig.swift @@ -143,8 +143,7 @@ struct PowerConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty power config") - _ = bleManager.requestPowerConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 938a9202..13f40f98 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -204,8 +204,7 @@ struct SecurityConfig: View { } else { if node.deviceConfig == nil { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty security config") - _ = bleManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } From 6b001471d555aa04736831dfd85929c256c1e2e7 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 13 May 2025 06:29:39 -0700 Subject: [PATCH 13/16] Revert "Additional accessibilityLabels for VoiceOver users (take #2)" --- Localizable.xcstrings | 480 +----------------- Meshtastic/Extensions/String.swift | 11 - Meshtastic/Views/Bluetooth/Connect.swift | 29 -- .../Helpers/BLESignalStrengthIndicator.swift | 82 ++- Meshtastic/Views/Helpers/BatteryCompact.swift | 89 +--- Meshtastic/Views/Helpers/BatteryGauge.swift | 35 +- .../Views/Helpers/ConnectedDevice.swift | 50 +- .../RequestPositionButton.swift | 1 - .../TextMessageField/TextMessageSize.swift | 2 - .../Helpers/Actions/IgnoreNodeButton.swift | 1 - .../Views/Nodes/Helpers/NodeDetail.swift | 165 +++--- .../Views/Nodes/Helpers/NodeInfoItem.swift | 4 - .../Views/Nodes/Helpers/NodeListItem.swift | 95 +--- Meshtastic/Views/Nodes/NodeList.swift | 5 - 14 files changed, 154 insertions(+), 895 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 16941a31..c62f4e6a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" -} +} \ No newline at end of file diff --git a/Meshtastic/Extensions/String.swift b/Meshtastic/Extensions/String.swift index 6a57da9e..d2ae1e5a 100644 --- a/Meshtastic/Extensions/String.swift +++ b/Meshtastic/Extensions/String.swift @@ -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 { diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 60bb2072..5e9dd834 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -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) { diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index 73d38f98..c5d17f16 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -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: 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 bb9819a2..4ac61d0c 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -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") } } diff --git a/Meshtastic/Views/Helpers/BatteryGauge.swift b/Meshtastic/Views/Helpers/BatteryGauge.swift index 81e81e7e..952c9768 100644 --- a/Meshtastic/Views/Helpers/BatteryGauge.swift +++ b/Meshtastic/Views/Helpers/BatteryGauge.swift @@ -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 { diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index 4a46db41..c795b1b0 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -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) } } } diff --git a/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift b/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift index fd166f51..2f1634bc 100644 --- a/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift +++ b/Meshtastic/Views/Messages/TextMessageField/RequestPositionButton.swift @@ -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) diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift index 9839e246..aacbd60d 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageSize.swift @@ -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) diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index 2d73d5c0..84fdf4d3 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -40,7 +40,6 @@ 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 c5670e06..081e7adc 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -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") { diff --git a/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift index eb4c37b0..07f3d92c 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeInfoItem.swift @@ -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) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index 62dd5fd0..2978ceab 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -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 { diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 9af36fbd..a17a19d0 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -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 { From 8dfa7f5b1441467736ff494ce185b1e751229d2f Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 13 May 2025 06:40:50 -0700 Subject: [PATCH 14/16] Bump timestamp up to an hour --- Meshtastic/Extensions/CoreData/MessageEntityExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index 0cfbd579..e7abb191 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -34,7 +34,7 @@ extension MessageEntity { func displayTimestamp(aboveMessage: MessageEntity?) -> Bool { if let aboveMessage = aboveMessage { - return aboveMessage.timestamp.addingTimeInterval(900) < timestamp // 15 minutes + return aboveMessage.timestamp.addingTimeInterval(3600) < timestamp // 60 minutes } return false // First message will have no timestamp } From 1b701ba40d460b3dbb72a60d27fe37720a5da4c9 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 13 May 2025 07:35:49 -0700 Subject: [PATCH 15/16] Fix logging type --- Localizable.xcstrings | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index c62f4e6a..b97eb676 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -22467,8 +22467,7 @@ } } }, - "Positon config received: %@" : { - "extractionState" : "stale", + "Position config received: %@" : { "localizations" : { "de" : { "stringUnit" : { From 980debd8e8529f0ba6ab90afa2e673e3d1a46aa6 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 13 May 2025 10:01:44 -0700 Subject: [PATCH 16/16] Auto favorite node when sending a DM --- Meshtastic/Helpers/BLEManager.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index b5971896..e26d6245 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -1120,6 +1120,19 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if newMessage.toUser?.pkiEncrypted ?? false { meshPacket.pkiEncrypted = true meshPacket.publicKey = newMessage.toUser?.publicKey ?? Data() + // Auto Favorite nodes you DM so they don't roll out of the nodedb + if !(newMessage.toUser?.userNode?.favorite ?? true) { + newMessage.toUser?.userNode?.favorite = true + do { + try context.save() + Logger.data.info("💾 Auto favorited node bases on sending a message \(self.connectedPeripheral.num.toHex(), privacy: .public) to \(toUserNum.toHex(), privacy: .public)") + _ = self.setFavoriteNode(node: (newMessage.toUser?.userNode)!, connectedNodeNum: fromUserNum) + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved Core Data error when auto favoriting in Send Message Function. Error: \(nsError, privacy: .public)") + } + } } meshPacket.id = UInt32(newMessage.messageId) if toUserNum > 0 {