From 6bb880d503a81e333235703a02af2f09ed85f399 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 24 Sep 2025 20:55:42 -0700 Subject: [PATCH] Two Column Node List (#1425) * Restore BLE State * Log privacy * AccessoryManager to handle restored connection * Comment task out * Switch the node list to a two column layout * Keep asian translations of channel details string * Update restore state function based on conversation with jake * Update Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * always show node list search bar * Update auto correct modifier * Dont show online animations for ios 17, remove online animation from node map, remove online circle from position popover * Work in progress. * Update detents * Gate the discovery process while restoring * Use geometry reader to size weather tiles on node details * Update BLE Transport * Update location weather condistion styles * Log privacy in didReceive * Remove extra dividers from admin key config, fix onboarding typo * Bump minimum catalyst target * Bump mac target version * Use @FetchRequest for UserList to try and use less memory on ios 17 * Revert change to @fetchrequest * Stab in the dark for Devices crash * Updated UserList (back?) to @FetchRequest * Set mac minimum to 15 * Nil out continuation after use * Use @FetchRequest for the node list to stop crashes on iOS 17 * Handle failed connections during restoration --------- Co-authored-by: Jake-B Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Localizable.xcstrings | 71 +-- Meshtastic.xcodeproj/project.pbxproj | 16 +- .../Meshtastic (Background BLE).xcscheme | 78 ++++ .../AccessoryManager+Connect.swift | 33 +- .../AccessoryManager+Discovery.swift | 2 +- .../Accessory Manager/AccessoryManager.swift | 2 +- Meshtastic/Accessory/Helpers/AsyncGate.swift | 15 + Meshtastic/Accessory/Protocols/Device.swift | 3 +- .../Bluetooth Low Energy/BLETransport.swift | 159 +++++-- .../Weather/LocalWeatherConditions.swift | 69 +-- Meshtastic/Views/Messages/UserList.swift | 324 ++++++------- .../Map/MapContent/AnimatedNodePin.swift | 4 +- .../Map/MapContent/NodeMapContent.swift | 71 ++- .../Nodes/Helpers/Map/MapSettingsForm.swift | 2 +- .../Nodes/Helpers/Map/PositionPopover.swift | 5 - .../Views/Nodes/Helpers/NodeDetail.swift | 73 ++- .../Views/Nodes/Helpers/NodeListFilter.swift | 2 +- .../Views/Nodes/Helpers/NodeListItem.swift | 413 ++++++++--------- .../PreferenceKeys/TileHeightKeys.swift | 26 ++ Meshtastic/Views/Nodes/NodeList.swift | 437 +++++++++--------- .../Views/Onboarding/DeviceOnboarding.swift | 2 +- .../Views/Settings/Channels/ChannelForm.swift | 3 +- .../Settings/Config/SecurityConfig.swift | 2 - .../Views/Settings/Logs/AppLogFilter.swift | 2 +- 24 files changed, 973 insertions(+), 841 deletions(-) create mode 100644 Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic (Background BLE).xcscheme create mode 100644 Meshtastic/Views/Nodes/Helpers/PreferenceKeys/TileHeightKeys.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 7e286dc7..94ed462f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -6918,8 +6918,8 @@ } } }, - "channel details" : { - "localizations" : { + "Channel Details" : { + "localizations" : { "it" : { "stringUnit" : { "state" : "translated", @@ -31298,6 +31298,9 @@ } } } + }, + "Select a Node" : { + }, "Select a node from the drop down to manage connected or remote devices." : { "localizations" : { @@ -31387,70 +31390,6 @@ } } }, - "Select Node" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knoten auswählen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sélectioner un noeud" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר מכשיר" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selezionare un nodo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノード選択" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz węzeł" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en nod" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Одабери чвор" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择一个节点" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇節點" - } - } - } - }, "Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center." : { "localizations" : { "de" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 384c374f..9a2df54e 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -658,6 +658,10 @@ DDFFA7462B3A7F3C004730DB /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = PreferenceKeys; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 25F5D5C42C4375A8008036E3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -1317,14 +1321,15 @@ DDDB26402AABEF7B003AFCB7 /* Helpers */ = { isa = PBXGroup; children = ( + DD4C11E02E8099C3003F2F2E /* PreferenceKeys */, 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */, 231B3F232D087C020069A07D /* Metrics Columns */, DDAD49EB2AFAE82500B4425D /* Map */, DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */, DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */, DDDB26412AABF655003AFCB7 /* NodeListItem.swift */, - 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */, DDDCD56F2BB26F5C00BE6B60 /* NodeListFilter.swift */, + 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */, 251926882C3BAF2E00249DF5 /* Actions */, BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */, ); @@ -1423,6 +1428,9 @@ dependencies = ( DDDE5A0229AF163E00490C6C /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + DD4C11E02E8099C3003F2F2E /* PreferenceKeys */, + ); name = Meshtastic; packageProductDependencies = ( DD0D3D212A55CEB10066DB71 /* CocoaMQTT */, @@ -2072,6 +2080,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 2.7.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2106,6 +2115,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 2.7.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2137,6 +2147,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 2.7.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2169,6 +2180,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 2.7.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic (Background BLE).xcscheme b/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic (Background BLE).xcscheme new file mode 100644 index 00000000..78d0f31b --- /dev/null +++ b/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic (Background BLE).xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index 29983265..74749d25 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -14,8 +14,8 @@ private let maxRetries = 10 private let retryDelay: Duration = .seconds(1) extension AccessoryManager { - func connect(to device: Device, withConnection: Connection? = nil) async throws { - + func connect(to device: Device, withConnection: Connection? = nil, wantConfig: Bool = true, wantDatabase: Bool = true, versionCheck: Bool = true) async throws { + Logger.transport.info("AccessoryManager.connect(to: \(device.name, privacy: .public), withConnection: \(withConnection != nil), wantConfig: \(wantConfig), wantDatabase: \(wantDatabase), versionCheck: \(versionCheck))") // Prevent new connection if one is active if activeConnection != nil { throw AccessoryError.connectionFailed("Already connected to a device") @@ -77,24 +77,40 @@ extension AccessoryManager { // Step 2: Send Heartbeat before wantConfig (config) Step { @MainActor _ in + guard wantConfig else { + Logger.transport.info("👟 [Connect] Step 2: wantConfig = false, skipping heartbeat") + return + } Logger.transport.info("💓👟 [Connect] Step 2: Send heartbeat") try await self.sendHeartbeat() } // Step 3: Send WantConfig (config) Step(timeout: .seconds(30)) { @MainActor _ in + guard wantConfig else { + Logger.transport.info("👟 [Connect] Step 4: wantConfig = false, skipping wantConfig") + return + } Logger.transport.info("🔗👟 [Connect] Step 3: Send wantConfig (config)") try await self.sendWantConfig() } // Step 4: Send Heartbeat before wantConfig (database) Step { @MainActor _ in + guard wantDatabase else { + Logger.transport.info("👟 [Connect] Step 4: wantDatabase = false, skipping heartbeat") + return + } Logger.transport.info("💓 [Connect] Step 4: Send heartbeat") try await self.sendHeartbeat() } // Step 5: Send WantConfig (database) Step(timeout: .seconds(3.0), onFailure: .retryStep(attempts: 3)) { @MainActor _ in + guard wantDatabase else { + Logger.transport.info("👟 [Connect] Step 5: wantDatabase = false, skipping wantDatabase") + return + } Logger.transport.info("🔗👟 [Connect] Step 5: Send wantConfig (database)") self.updateState(.retrievingDatabase(nodeCount: 0)) self.allowDisconnect = true @@ -103,12 +119,20 @@ extension AccessoryManager { // Step 5a: Wait for end of WantConfig (database) Step { @MainActor _ in + guard wantDatabase else { + Logger.transport.info("👟 [Connect] Step 4: wantDatabase = false, skipping waitForWantDatabase") + return + } Logger.transport.info("🔗👟 [Connect] Step 5a: Wait for the final database") try await self.waitForWantDatabaseResponse() } // Step 6: Version check Step { @MainActor _ in + guard versionCheck else { + Logger.transport.info("👟 [Connect] Step 6: versionCheck = false, skipping version check") + return + } Logger.transport.info("🔗👟 [Connect] Step 6: Version check") guard let firmwareVersion = self.activeConnection?.device.firmwareVersion else { @@ -138,6 +162,9 @@ extension AccessoryManager { // Send time to device try? await self.sendTime() + // Allow disconnect here too + self.allowDisconnect = true + // We have an active connection self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connected) self.updateState(.subscribed) @@ -153,8 +180,6 @@ extension AccessoryManager { await self.setupPeriodicHeartbeat() } - - if let device = self.activeConnection?.device { var version: String? if let firmwareVersion = device.firmwareVersion { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift index f3e88224..831ffe30 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -67,7 +67,7 @@ extension AccessoryManager { self.devices = devices.sorted { $0.name < $1.name } case .deviceLost(let deviceId): - devices.removeAll { $0.id == deviceId } + devices = devices.filter { $0.id != deviceId } case .deviceReportedRssi(let deviceId, let newRssi): updateDevice(deviceId: deviceId, key: \.rssi, value: newRssi) diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index d9aa53b1..3c507a03 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -402,7 +402,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { shouldAutomaticallyConnectToPreferredPeripheral = true } - Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheral, privacy: .public))") + Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription, privacy: .public) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheral, privacy: .public))") lastConnectionError = error diff --git a/Meshtastic/Accessory/Helpers/AsyncGate.swift b/Meshtastic/Accessory/Helpers/AsyncGate.swift index 7f20ce83..4c232b83 100644 --- a/Meshtastic/Accessory/Helpers/AsyncGate.swift +++ b/Meshtastic/Accessory/Helpers/AsyncGate.swift @@ -45,6 +45,20 @@ actor AsyncGate { waiters.removeAll() } + /// Fails all current waiters with the provided error. + /// - Parameter error: The error to throw to all waiters. + func throwAll(_ error: Error) { + for (_, cont) in waiters { + cont.resume(throwing: error) + } + waiters.removeAll() + } + + /// Alias for `throwAll(_:)` for readability. + func fail(_ error: Error) { + throwAll(error) + } + /// Resets the gate back to closed. /// Future waiters will suspend again until `open()` is called. func reset() { @@ -57,3 +71,4 @@ actor AsyncGate { } } } + diff --git a/Meshtastic/Accessory/Protocols/Device.swift b/Meshtastic/Accessory/Protocols/Device.swift index d24a1eb6..27cd0204 100644 --- a/Meshtastic/Accessory/Protocols/Device.swift +++ b/Meshtastic/Accessory/Protocols/Device.swift @@ -23,13 +23,14 @@ struct Device: Identifiable, Hashable { var connectionState: ConnectionState - init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil) { + init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil) { self.id = id self.name = name self.transportType = transportType self.identifier = identifier self.connectionState = connectionState self.rssi = rssi + self.num = num } var rssiString: String { diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift index c91b21ff..a89eec31 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift @@ -16,16 +16,16 @@ class BLETransport: Transport { private let kCentralRestoreID = "com.meshtastic.central" let type: TransportType = .ble - private var centralManager: CBCentralManager? + private var centralManager: CBCentralManager private var discoveredPeripherals: [UUID: (peripheral: CBPeripheral, lastSeen: Date)] = [:] private var discoveredDeviceContinuation: AsyncStream.Continuation? private let delegate: BLEDelegate private var connectingPeripheral: CBPeripheral? private var activeConnection: BLEConnection? private var connectContinuation: CheckedContinuation? - private var setupCompleteContinuation: CheckedContinuation? - - + private var restoredConnectContinuation: CheckedContinuation? + private var setupCompleteGate: AsyncGate + private var restoreInProgress: Bool = false var status: TransportStatus = .uninitialized private var cleanupTask: Task? @@ -35,10 +35,14 @@ class BLETransport: Transport { let requiresPeriodicHeartbeat = false init() { - self.centralManager = nil self.discoveredPeripherals = [:] self.discoveredDeviceContinuation = nil self.delegate = BLEDelegate() + self.setupCompleteGate = AsyncGate() + centralManager = CBCentralManager(delegate: delegate, + queue: .global(qos: .utility), + options: [CBCentralManagerOptionRestoreIdentifierKey: kCentralRestoreID] + ) self.delegate.setTransport(self) } @@ -46,11 +50,22 @@ class BLETransport: Transport { AsyncStream { cont in Task { self.discoveredDeviceContinuation = cont - if self.centralManager == nil { - try await self.setupCentralManager() - } - centralManager?.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + // This gate is opened when the CBCentralManager is in poweredOn state. + // Its probably open already, but just to be sure in case we get here too quickly. + try await self.setupCompleteGate.wait() + + if !restoreInProgress { + centralManager.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + + for alreadyDiscoveredPeripheral in self.discoveredPeripherals.values.map({$0.peripheral}) { + let device = Device(id: alreadyDiscoveredPeripheral.identifier, + name: alreadyDiscoveredPeripheral.name ?? "Unknown", + transportType: .ble, + identifier: alreadyDiscoveredPeripheral.identifier.uuidString) + cont.yield(.deviceFound(device)) + } + } setupCleanupTask() } cont.onTermination = { _ in @@ -82,27 +97,16 @@ class BLETransport: Transport { } } - private func setupCentralManager() async throws { - try await withCheckedThrowingContinuation { cont in - self.setupCompleteContinuation = cont - centralManager = CBCentralManager(delegate: delegate, - queue: .global(qos: .utility), - options: [CBCentralManagerOptionRestoreIdentifierKey: kCentralRestoreID] - ) - } - } - private func stopScanning() { Logger.transport.debug("🛜 [BLE] Stop Scanning: BLE Discovery has been stopped.") - centralManager?.stopScan() + centralManager.stopScan() discoveredPeripherals.removeAll() discoveredDeviceContinuation = nil - if let state = centralManager?.state, state == .poweredOn { + if centralManager.state == .poweredOn { status = .ready } else { status = .uninitialized } - centralManager = nil cleanupTask?.cancel() cleanupTask = nil } @@ -115,10 +119,11 @@ class BLETransport: Transport { Logger.transport.info("🛜 [BLE] CBManager has poweredOn with an already active connection") } status = .discovering - self.setupCompleteContinuation?.resume() - self.setupCompleteContinuation = nil - if self.discoveredDeviceContinuation != nil { + // Open the gate, so anyone who was waiitng for poweredOn can continue + Task { await self.setupCompleteGate.open() } + + if self.discoveredDeviceContinuation != nil && !restoreInProgress { // We have someone already subscribed to our discovery event stream. // Likely a powerOff event occcurred and need to now restore scanning. central.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) @@ -134,18 +139,17 @@ class BLETransport: Transport { } } status = .ready - self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is powered off")) - self.setupCompleteContinuation = nil + + // Close the gate to make people wait + Task { await setupCompleteGate.reset() } case .unauthorized: status = .error("Bluetooth access is unauthorized") - self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is unauthorized")) - self.setupCompleteContinuation = nil + Task { await self.setupCompleteGate.throwAll(AccessoryError.connectionFailed("Bluetooth is unauthorized")) } case .unsupported: status = .error("Bluetooth is unsupported on this device") - self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is unsupported")) - self.setupCompleteContinuation = nil + Task { await self.setupCompleteGate.throwAll(AccessoryError.connectionFailed("Bluetooth is unsupported"))} case .resetting: status = .error("Bluetooth is resetting") @@ -156,12 +160,13 @@ class BLETransport: Transport { // Perhaps wait @unknown default: status = .error("Unknown Bluetooth state") - self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Unknown Bluetooth State")) - self.setupCompleteContinuation = nil + Task { await self.setupCompleteGate.throwAll(AccessoryError.connectionFailed("Unknown Bluetooth State"))} } } func didDiscover(peripheral: CBPeripheral, rssi: NSNumber) { + guard !restoreInProgress else { return } + let id = peripheral.identifier let isNew = discoveredPeripherals[id] == nil if isNew { @@ -187,9 +192,6 @@ class BLETransport: Transport { guard let peripheral = discoveredPeripherals[UUID(uuidString: device.identifier)!] else { throw AccessoryError.connectionFailed("Peripheral not found") } - guard let cm = centralManager else { - throw AccessoryError.connectionFailed("Central manager not available") - } if await self.activeConnection?.peripheral.state == .disconnected { Logger.transport.error("🛜 [BLE] Connect request while an active (but disconnected)") @@ -204,7 +206,7 @@ class BLETransport: Transport { } self.connectContinuation = cont self.connectingPeripheral = peripheral.peripheral - cm.connect(peripheral.peripheral) + centralManager.connect(peripheral.peripheral) } self.activeConnection = newConnection return newConnection @@ -271,6 +273,11 @@ class BLETransport: Transport { } func handleDidConnect(peripheral: CBPeripheral, central: CBCentralManager) { + if let restoredConnectContinuation { + restoredConnectContinuation.resume() + self.restoredConnectContinuation = nil + return + } Logger.transport.debug("🛜 [BLE] Handle Did Connect Connected to peripheral \(peripheral.name ?? "Unknown", privacy: .public)") guard let cont = connectContinuation, let connPeripheral = connectingPeripheral, @@ -284,6 +291,12 @@ class BLETransport: Transport { } func handleDidFailToConnect(peripheral: CBPeripheral, error: Error?) { + if let restoredConnectContinuation { + restoredConnectContinuation.resume(throwing: AccessoryError.connectionFailed("Connection failed during restoration")) + self.restoredConnectContinuation = nil + return + } + guard let cont = connectContinuation, let connPeripheral = connectingPeripheral, peripheral.identifier == connPeripheral.identifier else { @@ -300,26 +313,75 @@ class BLETransport: Transport { /// look in the logs for the messages below. Logger.transport.error("🛜 [BLE] Will Restore State was called. Attempting to restore connection.") - self.centralManager = central - /// Find the peripheral that was connected before guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], let peripheral = peripherals.first else { Logger.transport.error("🛜 [BLE] No peripherals found in restore state dictionary.") return } - let id = peripheral.identifier - let device = Device(id: id, name: peripheral.name ?? "Unknown", transportType: .ble, identifier: id.uuidString) + // Prevent device discovery during the restore process + restoreInProgress = true + + // Create a device object + // TODO: maybe serialize the whole device into UserDefaults on connect? + let id = peripheral.identifier + let nodeNum = UserDefaults.preferredPeripheralNum != 0 ? Int64(UserDefaults.preferredPeripheralNum) : nil + let device = Device(id: id, name: peripheral.name ?? "Unknown", transportType: .ble, identifier: id.uuidString, num: nodeNum) + discoveredPeripherals[id] = (peripheral: peripheral, lastSeen: Date()) + Logger.transport.error("🛜 [BLE] Found peripheral to restore: \(peripheral.name ?? "Unknown", privacy: .public) ID: \(peripheral.identifier, privacy: .public) State: \(cbPeripheralStateDescription(peripheral.state), privacy: .public).") /// Create a new BLEConnection object and set it as the active connection if the state is connected - if peripheral.state == .connected { - let restoredConnection = BLEConnection(peripheral: peripheral, central: central, transport: self) - self.activeConnection = restoredConnection - Logger.transport.error("🛜 [BLE] Peripheral Connection found and state is connected setting this connection as the activeConnection.") + + // Begin a background task to handle the process. + Task { + switch peripheral.state { + case .connecting: + let restoredConnection = BLEConnection(peripheral: peripheral, central: central, transport: self) + self.activeConnection = restoredConnection + Task { + do { + // Make sure we're in poweredOn before continuing + try await self.setupCompleteGate.wait() + + Logger.transport.error("🛜 [BLE] Restoring peripheral in connecting state. Waiting for didConnect from delegate.") + + // Complete the connect with centralManager.connect and wait for the didConnect. + try await withCheckedThrowingContinuation { cont in + self.restoredConnectContinuation = cont + centralManager.connect(peripheral) + } + + Logger.transport.error("🛜 [BLE] Restoring peripheral in connecting state. ✅ didConnect Received!") + Task { @MainActor in + // In this case we need a full reconnect, so do the wantConfig, wantDatabase, and versionCheck + try? await AccessoryManager.shared.connect(to: device, withConnection: restoredConnection, wantConfig: true, wantDatabase: true, versionCheck: true) + restoreInProgress = false + } + } catch { + // We had a conneciton failure during restoration. + Logger.transport.error("🛜 [BLE] Error restoring peripheral in connecting state. \(error, privacy: .public)") + restoreInProgress = false + } + } + + case .connected: + let restoredConnection = BLEConnection(peripheral: peripheral, central: central, transport: self) + self.activeConnection = restoredConnection + Logger.transport.error("🛜 [BLE] Peripheral Connection found and state is connected setting this connection as the activeConnection.") + Task { @MainActor in + // In this case we need a full reconnect, so do the wantConfig, wantDatabase, and versionCheck + try? await AccessoryManager.shared.connect(to: device, withConnection: restoredConnection, wantConfig: false, wantDatabase: false, versionCheck: false) + restoreInProgress = false + } + Logger.transport.error("🛜 [BLE] Connection state successfully restored in the background.") + default: + // Since we're not going to attempt to reconnect in then allow normal device discovery + Logger.transport.error("🛜 [BLE] Unhandled state restoration for state: \(cbPeripheralStateDescription(peripheral.state), privacy: .public).") + restoreInProgress = false + } } - /// Otherwise let the existing reconnection logic in the accessory manager handle reconnection for us - Logger.transport.error("🛜 [BLE] Connection state successfully restored in the background.") + } func manuallyConnect(withConnectionString: String) async throws { @@ -330,6 +392,7 @@ class BLETransport: Transport { func connectionDidDisconnect() { self.activeConnection = nil self.connectingPeripheral = nil + restoreInProgress = false } } @@ -362,8 +425,10 @@ class BLEDelegate: NSObject, CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { if let error = error as? NSError { + Logger.transport.error("🛜 [BLETransport] Error while disconnecting peripheral: \(peripheral.name ?? ""): \(error)") transport?.handlePeripheralDisconnectError(peripheral: peripheral, error: error) } else { + Logger.transport.error("🛜 [BLETransport] Did succesfully disconnect peripheral: \(peripheral.name ?? "")") transport?.handlePeripheralDisconnect(peripheral: peripheral) } } diff --git a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift index f8ba74a8..def3ae6c 100644 --- a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift +++ b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift @@ -1,16 +1,9 @@ -// -// LocalWeatherConditions.swift -// Meshtastic -// -// Created by Garth Vander Houwen on 7/9/24. -// import SwiftUI import MapKit import WeatherKit import OSLog struct LocalWeatherConditions: View { - private let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2) @State var location: CLLocation? /// Weather /// The current weather condition for the city. @@ -26,19 +19,49 @@ struct LocalWeatherConditions: View { @State private var symbolName: String = "cloud.fill" @State private var attributionLink: URL? @State private var attributionLogo: URL? - @Environment(\.colorScheme) var colorScheme: ColorScheme + var body: some View { if location != nil { - VStack { - LazyVGrid(columns: gridItemLayout) { - WeatherConditionsCompactWidget(temperature: temperature, symbolName: symbolName, description: condition?.description.uppercased() ?? "??") - HumidityCompactWidget(humidity: humidity ?? 0, dewPoint: dewPoint) - PressureCompactWidget(pressure: String(pressure?.value ?? 0.0 / 100), unit: pressure?.unit.symbol ?? "??", low: pressure?.value ?? 0.0 <= 1009.144) - WindCompactWidget(speed: windSpeed, gust: windGust, direction: windCompassDirection) + GeometryReader { geometry in + VStack(alignment: .center) { + // Determine the number of columns based on the screen width + let columns = geometry.size.width > 600 ? 4 : 2 + let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 20), count: columns) + + LazyVGrid(columns: gridItemLayout) { + WeatherConditionsCompactWidget(temperature: temperature, symbolName: symbolName, description: condition?.description.uppercased() ?? "??") + .padding(.bottom, columns == 2 ? 10 : 0) + HumidityCompactWidget(humidity: humidity ?? 0, dewPoint: dewPoint) + .padding(.bottom, columns == 2 ? 10 : 0) + PressureCompactWidget(pressure: String(pressure?.value ?? 0.0 / 100), unit: pressure?.unit.symbol ?? "??", low: pressure?.value ?? 0.0 <= 1009.144) + WindCompactWidget(speed: windSpeed, gust: windGust, direction: windCompassDirection) + } + + HStack { + AsyncImage(url: attributionLogo) { image in + image + .resizable() + .scaledToFit() + .frame(height: 10) + } placeholder: { + ProgressView() + .controlSize(.mini) + } + Link("Other data sources", destination: attributionLink ?? URL(string: "https://weather-data.apple.com/legal-attribution.html")!) + .font(.caption2) + } + .offset(y: -2) + .padding(.bottom, -15) } + .background( + // Use GeometryReader here to get the VGrid's height + GeometryReader { proxy in + // Set the preference key with the VGrid's height + Color.clear.preference(key: WeatherKitTilesHeightKey.self, value: proxy.size.height) + } + ) } - .padding(.top) .task { do { if location != nil { @@ -69,22 +92,6 @@ struct LocalWeatherConditions: View { symbolName = "cloud.fill" } } - VStack { - HStack { - AsyncImage(url: attributionLogo) { image in - image - .resizable() - .scaledToFit() - } placeholder: { - ProgressView() - .controlSize(.mini) - } - .frame(height: 10) - Link("Other data sources", destination: attributionLink ?? URL(string: "https://weather-data.apple.com/legal-attribution.html")!) - .font(.caption2) - } - .padding(2) - } } } } diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index c41a2391..260f0b45 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -20,163 +20,10 @@ struct UserList: View { @StateObject private var filters: NodeFilterParameters = NodeFilterParameters() @Binding var node: NodeInfoEntity? @Binding var userSelection: UserEntity? - @State private var userToDeleteMessages: UserEntity? - @State private var isPresentingDeleteUserMessagesConfirm: Bool = false - - private func fetchUsers(withFilters: NodeFilterParameters) -> [UserEntity] { - let request: NSFetchRequest = UserEntity.fetchRequest() - request.sortDescriptors = [ - NSSortDescriptor(key: "lastMessage", ascending: false), - NSSortDescriptor(key: "userNode.favorite", ascending: false), - NSSortDescriptor(key: "pkiEncrypted", ascending: false), - NSSortDescriptor(key: "userNode.lastHeard", ascending: false), - NSSortDescriptor(key: "longName", ascending: true) - ] - request.predicate = withFilters.buildPredicate() - return (try? context.fetch(request)) ?? [] - } var body: some View { - let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY") - let users = fetchUsers(withFilters: filters) VStack { - List(users, selection: $userSelection) { user in - let mostRecent = user.messageList.last - let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) - let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 - let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 - if user.num != accessoryManager.activeDeviceNum ?? 0 { - NavigationLink(value: user) { - ZStack { - Image(systemName: "circle.fill") - .opacity(user.unreadMessages > 0 ? 1 : 0) - .font(.system(size: 10)) - .foregroundColor(.accentColor) - .brightness(0.2) - } - - CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num)))) - - VStack(alignment: .leading) { - HStack { - if user.pkiEncrypted { - if !user.keyMatch { - /// Public Key on the User and the Public Key on the Last Message don't match - Image(systemName: "key.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else { - Image(systemName: "lock.open.fill") - .foregroundColor(.yellow) - } - Text(user.longName ?? "Unknown".localized) - .font(.headline) - .allowsTightening(true) - Spacer() - if user.userNode?.favorite ?? false { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - } - if user.messageList.count > 0 { - if lastMessageDay == currentDay { - Text(lastMessageTime, style: .time ) - .font(.footnote) - .foregroundColor(.secondary) - } else if lastMessageDay == (currentDay - 1) { - Text("Yesterday") - .font(.footnote) - .foregroundColor(.secondary) - } else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) { - Text(lastMessageTime.formattedDate(format: dateFormatString)) - .font(.footnote) - .foregroundColor(.secondary) - } else if lastMessageDay < (currentDay - 1800) { - Text(lastMessageTime.formattedDate(format: dateFormatString)) - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - - if user.messageList.count > 0 { - HStack(alignment: .top) { - Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - } - .frame(height: 62) - .alignmentGuide(.listRowSeparatorLeading) { - $0[.leading] - } - .contextMenu { - Button { - if node != nil && !(user.userNode?.favorite ?? false) { - user.userNode?.favorite = !(user.userNode?.favorite ?? false) - Task { - try await accessoryManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) - Logger.data.info("Favorited a node") - } - } else { - user.userNode?.favorite = !(user.userNode?.favorite ?? false) - Task { - try await accessoryManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) - Logger.data.info("Unfavorited a node") - } - } - context.refresh(user, mergeChanges: true) - do { - try context.save() - } catch { - context.rollback() - Logger.data.error("Save Node Favorite Error") - } - } label: { - Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill") - } - Button { - user.mute = !user.mute - do { - try context.save() - } catch { - context.rollback() - Logger.data.error("Save User Mute Error") - } - } label: { - Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash") - } - if user.messageList.count > 0 { - Button(role: .destructive) { - isPresentingDeleteUserMessagesConfirm = true - userToDeleteMessages = user - } label: { - Label("Delete Messages", systemImage: "trash") - } - } - } - .confirmationDialog( - "This conversation will be deleted.", - isPresented: $isPresentingDeleteUserMessagesConfirm, - titleVisibility: .visible - ) { - Button(role: .destructive) { - deleteUserMessages(user: userToDeleteMessages!, context: context) - context.refresh(node!.user!, mergeChanges: true) - } label: { - Text("Delete") - } - } - } - } - .listStyle(.plain) - .navigationTitle(String.localizedStringWithFormat("Contacts (%@)".localized, String(users.count))) - + FilteredUserList(withFilters: filters, node: $node, userSelection: $userSelection) .sheet(isPresented: $editingFilters) { NodeListFilter(filterTitle: "Contact Filters", filters: filters) } @@ -214,12 +61,179 @@ struct UserList: View { } .padding(.bottom, 5) .searchable(text: $filters.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a contact") - .disableAutocorrection(true) + .autocorrectionDisabled(true) .scrollDismissesKeyboard(.immediately) } } } +fileprivate struct FilteredUserList: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.managedObjectContext) var context + + @FetchRequest private var users: FetchedResults + @Binding var userSelection: UserEntity? + @Binding var node: NodeInfoEntity? + + @State private var isPresentingDeleteUserMessagesConfirm: Bool = false + @State private var userToDeleteMessages: UserEntity? + + init(withFilters: NodeFilterParameters, node: Binding, userSelection: Binding) { + let request: NSFetchRequest = UserEntity.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(key: "lastMessage", ascending: false), + NSSortDescriptor(key: "userNode.favorite", ascending: false), + NSSortDescriptor(key: "pkiEncrypted", ascending: false), + NSSortDescriptor(key: "userNode.lastHeard", ascending: false), + NSSortDescriptor(key: "longName", ascending: true) + ] + request.predicate = withFilters.buildPredicate() + self._users = FetchRequest(fetchRequest: request) + self._node = node + self._userSelection = userSelection + } + + var body: some View { + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY") + + List(users, selection: $userSelection) { user in + let mostRecent = user.messageList.last + let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) + let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 + let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 + if user.num != accessoryManager.activeDeviceNum ?? 0 { + NavigationLink(value: user) { + ZStack { + Image(systemName: "circle.fill") + .opacity(user.unreadMessages > 0 ? 1 : 0) + .font(.system(size: 10)) + .foregroundColor(.accentColor) + .brightness(0.2) + } + + CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num)))) + + VStack(alignment: .leading) { + HStack { + if user.pkiEncrypted { + if !user.keyMatch { + /// Public Key on the User and the Public Key on the Last Message don't match + Image(systemName: "key.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else { + Image(systemName: "lock.open.fill") + .foregroundColor(.yellow) + } + Text(user.longName ?? "Unknown".localized) + .font(.headline) + .allowsTightening(true) + Spacer() + if user.userNode?.favorite ?? false { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } + if user.messageList.count > 0 { + if lastMessageDay == currentDay { + Text(lastMessageTime, style: .time ) + .font(.footnote) + .foregroundColor(.secondary) + } else if lastMessageDay == (currentDay - 1) { + Text("Yesterday") + .font(.footnote) + .foregroundColor(.secondary) + } else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) { + Text(lastMessageTime.formattedDate(format: dateFormatString)) + .font(.footnote) + .foregroundColor(.secondary) + } else if lastMessageDay < (currentDay - 1800) { + Text(lastMessageTime.formattedDate(format: dateFormatString)) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + + if user.messageList.count > 0 { + HStack(alignment: .top) { + Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + } + .frame(height: 62) + .alignmentGuide(.listRowSeparatorLeading) { + $0[.leading] + } + .contextMenu { + Button { + if node != nil && !(user.userNode?.favorite ?? false) { + user.userNode?.favorite = !(user.userNode?.favorite ?? false) + Task { + try await accessoryManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) + Logger.data.info("Favorited a node") + } + } else { + user.userNode?.favorite = !(user.userNode?.favorite ?? false) + Task { + try await accessoryManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) + Logger.data.info("Unfavorited a node") + } + } + context.refresh(user, mergeChanges: true) + do { + try context.save() + } catch { + context.rollback() + Logger.data.error("Save Node Favorite Error") + } + } label: { + Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill") + } + Button { + user.mute = !user.mute + do { + try context.save() + } catch { + context.rollback() + Logger.data.error("Save User Mute Error") + } + } label: { + Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash") + } + if user.messageList.count > 0 { + Button(role: .destructive) { + isPresentingDeleteUserMessagesConfirm = true + userToDeleteMessages = user + } label: { + Label("Delete Messages", systemImage: "trash") + } + } + } + .confirmationDialog( + "This conversation will be deleted.", + isPresented: $isPresentingDeleteUserMessagesConfirm, + titleVisibility: .visible + ) { + Button(role: .destructive) { + deleteUserMessages(user: userToDeleteMessages!, context: context) + context.refresh(node!.user!, mergeChanges: true) + } label: { + Text("Delete") + } + } + } + } + .listStyle(.plain) + .navigationTitle(String.localizedStringWithFormat("Contacts (%@)".localized, String(users.count))) + } +} fileprivate extension NodeFilterParameters { func buildPredicate() -> NSPredicate? { var predicates: [NSPredicate] = [] diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift index 2099f6a6..6868f499 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift @@ -23,7 +23,9 @@ struct AnimatedNodePin: View, Equatable { ZStack { // Pass the calculatedDelay to the PulsingCircle view if isOnline { - PulsingCircle(nodeColor: nodeColor, calculatedDelay: calculatedDelay) + if #available(iOS 18, macOS 15, *) { + PulsingCircle(nodeColor: nodeColor, calculatedDelay: calculatedDelay) + } } if hasDetectionSensorMetrics { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift index 88d217ae..6059a57c 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift @@ -90,45 +90,40 @@ struct NodeMapContent: MapContent { Annotation(position.latest ? node.user?.shortName ?? "?": "", coordinate: position.coordinate) { LazyVStack { ZStack { - Circle() - .fill(Color(nodeColor.lighter()).opacity(0.4).shadow(.drop(color: Color(nodeColor).isLight() ? .black : .white, radius: 5))) - .foregroundStyle(Color(nodeColor.lighter()).opacity(0.3)) - .frame(width: 50, height: 50) - if pf.contains(.Heading) { - Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north" : "octagon") - .symbolEffect(.pulse.byLayer) - .padding(5) - .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) - .background(Color(nodeColor.darker())) - .clipShape(Circle()) - .rotationEffect(headingDegrees) - .onTapGesture { - selectedPosition = (selectedPosition == position ? nil : position) - } - .popover(item: $selectedPosition) { selection in - PositionPopover(position: selection) - .padding() - .opacity(0.8) - .presentationCompactAdaptation(.popover) - } + if pf.contains(.Heading) { + Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north" : "octagon") + .padding(5) + .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) + .background(Color(nodeColor.darker())) + .clipShape(Circle()) + .rotationEffect(headingDegrees) + .onTapGesture { + selectedPosition = (selectedPosition == position ? nil : position) + } + .popover(item: $selectedPosition) { selection in + PositionPopover(position: selection) + .padding() + .opacity(0.8) + .presentationCompactAdaptation(.popover) + } - } else { - Image(systemName: "flipphone") - .symbolEffect(.pulse.byLayer) - .padding(5) - .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) - .background(Color(UIColor(hex: UInt32(node.num)).darker())) - .clipShape(Circle()) - .onTapGesture { - selectedPosition = (selectedPosition == position ? nil : position) - } - .popover(item: $selectedPosition) { selection in - PositionPopover(position: selection) - .padding() - .opacity(0.8) - .presentationCompactAdaptation(.popover) - } - } + } else { + Image(systemName: "flipphone") + .symbolEffect(.pulse.byLayer) + .padding(5) + .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) + .background(Color(UIColor(hex: UInt32(node.num)).darker())) + .clipShape(Circle()) + .onTapGesture { + selectedPosition = (selectedPosition == position ? nil : position) + } + .popover(item: $selectedPosition) { selection in + PositionPopover(position: selection) + .padding() + .opacity(0.8) + .presentationCompactAdaptation(.popover) + } + } } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift index 7c04cbf8..baa2f919 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift @@ -216,7 +216,7 @@ struct MapSettingsForm: View { .padding(.bottom) #endif } - .presentationDetents([.medium, .large], selection: $currentDetent) + .presentationDetents([.large], selection: $currentDetent) .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled(upThrough: .medium)) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index f41c2f26..ca682060 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -25,11 +25,6 @@ struct PositionPopover: View { VStack { HStack { ZStack { - if position.nodePosition?.isOnline ?? false { - Circle() - .fill(Color(nodeColor.lighter()).opacity(0.4)) - .frame(width: 90, height: 90) - } CircleText(text: position.nodePosition?.user?.shortName ?? "?", color: Color(nodeColor), circleSize: 65) } Text(position.nodePosition?.user?.longName ?? "Unknown") diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 0fad8f98..108b409b 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -24,12 +24,10 @@ struct NodeDetail: View { @State private var showingShutdownConfirm: Bool = false @State private var showingRebootConfirm: Bool = false @State private var dateFormatRelative: Bool = true - // The node the device is currently connected to var connectedNode: NodeInfoEntity? - // The node information being displayed on the detail screen - @ObservedObject - var node: NodeInfoEntity - var columnVisibility = NavigationSplitViewVisibility.all + @ObservedObject var node: NodeInfoEntity + @State private var environmentSectionHeight: CGFloat = 0 + var body: some View { NavigationStack { List { @@ -116,31 +114,31 @@ struct NodeDetail: View { } .accessibilityElement(children: .combine) let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) - if let user = node.user, user.keyMatch { - let publicKey = node.num == connectedNode?.num - ? node.securityConfig?.publicKey?.base64EncodedString() ?? "" - : user.publicKey?.base64EncodedString() ?? "" - HStack { - Label { - Text("Public Key") - } icon: { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - Spacer() - Button(action: { - context.perform { - UIPasteboard.general.string = publicKey - } - }) { - HStack { - Image(systemName: "key.horizontal.fill") - Text("Copy") - } - } - } - .accessibilityElement(children: .combine) + if let user = node.user, user.keyMatch { + let publicKey = node.num == connectedNode?.num + ? node.securityConfig?.publicKey?.base64EncodedString() ?? "" + : user.publicKey?.base64EncodedString() ?? "" + HStack { + Label { + Text("Public Key") + } icon: { + Image(systemName: "lock.fill") + .foregroundColor(.green) } + Spacer() + Button(action: { + context.perform { + UIPasteboard.general.string = publicKey + } + }) { + HStack { + Image(systemName: "key.horizontal.fill") + Text("Copy") + } + } + } + .accessibilityElement(children: .combine) + } if let metadata = node.metadata { HStack { Label { @@ -245,17 +243,17 @@ struct NodeDetail: View { } } } - // Note, as you add widgets, you should add to the `hasDataForLatestPositions` array - // This will make sure the "Environment" section is only displayed when the node has a position - // to use with WeatherKit, or has actual data in the most recent EnvironmentMetrics entity - // that will be rendered in this section. 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 { + VStack(spacing: 0) { if !node.hasEnvironmentMetrics { LocalWeatherConditions(location: node.latestPosition?.nodeLocation) + .frame(height: environmentSectionHeight) // Use the state to set the frame + .onPreferenceChange(WeatherKitTilesHeightKey.self) { newHeight in + // Update the state with the new height + self.environmentSectionHeight = newHeight + } } else { VStack { if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 { @@ -328,7 +326,6 @@ struct NodeDetail: View { } } } - // Apply accessibility properties to the environment section .accessibilityElement(children: .combine) } } @@ -343,7 +340,6 @@ struct NodeDetail: View { } } Section("Logs") { - // Metrics NavigationLink { DeviceMetricsLog(node: node) } label: { @@ -499,7 +495,6 @@ struct NodeDetail: View { Logger.mesh.error("Faild to send node metadata request from node details") } } - } label: { Label { Text("Refresh device metadata") @@ -558,7 +553,7 @@ struct NodeDetail: View { } } .listStyle(.insetGrouped) - .navigationBarTitle(String(node.user?.longName?.addingVariationSelectors ?? "Unknown".localized), displayMode: .inline) + .navigationTitle(String(node.user?.longName?.addingVariationSelectors ?? "Unknown".localized)) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift index 73ee2897..3236f0d3 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift @@ -202,7 +202,7 @@ struct NodeListFilter: View { .padding(.bottom) #endif } - .presentationDetents([.fraction(0.80), .large]) + .presentationDetents([.large]) .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled(upThrough: .large)) diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index 5c6e72ad..23a97fd0 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -10,104 +10,94 @@ 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 { + + private var accessibilityDescription: String { + var desc = "" + if let shortName = node.user?.shortName { + desc = shortName.formatNodeNameForVoiceOver() + } else if let longName = node.user?.longName { + desc = longName + } else { desc = "Unknown".localized + " " + "Node".localized - } - if isDirectlyConnected { - 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 { + } + if isDirectlyConnected { + 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 { + if battery > 100 { desc += ", " + "Plugged in".localized - } else if battery == 100 { + } else if battery == 100 { desc += ", " + "Charging".localized - } else { - desc += ", battery \(battery)%" - } - } - // Add distance and heading/bearing if available, but only for non-connected nodes - if !isDirectlyConnected, 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) + } else { + desc += ", battery \(battery)%" + } + } + if !isDirectlyConnected, let (lastPosition, myCoord) = locationData { + let nodeCoord = CLLocation(latitude: lastPosition.nodeCoordinate!.latitude, longitude: lastPosition.nodeCoordinate!.longitude) + let metersAway = nodeCoord.distance(from: myCoord) + let distanceFormatter = LengthFormatter() + distanceFormatter.unitStyle = .medium + let formattedDistance = distanceFormatter.string(fromMeters: metersAway) desc += ", " + String(format: "%@: %@", "Distance".localized, 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 + 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)))) desc += ", " + "Heading".localized + " " + 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 = "Signal strength weak".localized - case .normal: - signalString = "Signal strength normal".localized - case .strong: - signalString = "Signal strength strong".localized - } - desc += ", " + signalString - } - return desc - } - + } + 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 = "Signal strength weak".localized + case .normal: + signalString = "Signal strength normal".localized + case .strong: + signalString = "Signal strength strong".localized + } + desc += ", " + signalString + } + return desc + } + @ObservedObject var node: NodeInfoEntity var isDirectlyConnected: Bool var connectedNode: Int64 var modemPreset: ModemPresets = ModemPresets(rawValue: UserDefaults.modemPreset) ?? ModemPresets.longFast - + var userKeyStatus: (String, Color) { var image = "lock.open.fill" var color = Color.yellow if node.user?.pkiEncrypted ?? false { if !(node.user?.keyMatch ?? false) { - /// Public Key on the User and the Public Key on the Last Message don't match image = "key.slash" color = .red } else { @@ -117,7 +107,7 @@ struct NodeListItem: View { } return (image, color) } - + var locationData: (PositionEntity, CLLocation)? { guard let lastPostion = node.positions?.lastObject as? PositionEntity else { return nil @@ -125,149 +115,146 @@ struct NodeListItem: View { guard let currentLocation = LocationsHandler.shared.locationsArray.last else { return nil } - + let myCoord = CLLocation(latitude: currentLocation.coordinate.latitude, longitude: currentLocation.coordinate.longitude) - + if lastPostion.nodeCoordinate != nil && myCoord.coordinate.longitude != LocationsHandler.DefaultLocation.longitude && myCoord.coordinate.latitude != LocationsHandler.DefaultLocation.latitude { - return (lastPostion, myCoord) + return (lastPostion, myCoord) } return nil } - + var body: some View { - NavigationLink(value: node) { - LazyVStack(alignment: .leading) { - HStack { - VStack(alignment: .center) { - CircleText(text: node.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node.num))), circleSize: 70) + LazyVStack(alignment: .leading) { + HStack { + VStack(alignment: .center) { + CircleText(text: node.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node.num))), circleSize: 70) + .padding(.trailing, 5) + if node.latestDeviceMetrics != nil { + BatteryCompact(batteryLevel: node.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor) .padding(.trailing, 5) - if node.latestDeviceMetrics != nil { - BatteryCompact(batteryLevel: node.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor) - .padding(.trailing, 5) - } } - VStack(alignment: .leading) { - HStack { - let (image, color) = userKeyStatus - IconAndText(systemName: image, - imageColor: color, - text: node.user?.longName?.addingVariationSelectors ?? "Unknown".localized, - textColor: .primary) - if node.favorite { - Spacer() - Image(systemName: "star.fill") - .symbolRenderingMode(.multicolor) - } - } - if isDirectlyConnected { - IconAndText(systemName: "antenna.radiowaves.left.and.right.circle.fill", - imageColor: .green, - text: "Connected".localized) - } - if node.lastHeard?.timeIntervalSince1970 ?? 0 > 0 && node.lastHeard! < Calendar.current.date(byAdding: .year, value: 1, to: Date())! { - IconAndText(systemName: node.isOnline ? "checkmark.circle.fill" : "moon.circle.fill", - imageColor: node.isOnline ? .green : .orange, - text: node.lastHeard?.formatted() ?? "Unknown Age".localized) - } - let role = DeviceRoles(rawValue: Int(node.user?.role ?? 0)) - IconAndText(systemName: role?.systemName ?? "figure", - text: "Role: \(role?.name ?? "Unknown".localized)") - if node.user?.unmessagable ?? false { - IconAndText(systemName: "iphone.slash", - renderingMode: .multicolor, - text: "Unmonitored") - } - if node.isStoreForwardRouter { - IconAndText(systemName: "envelope.arrow.triangle.branch", - renderingMode: .multicolor, - text: "Store & Forward".localized) - } - - if node.positions?.count ?? 0 > 0 && connectedNode != node.num { - HStack { - if let (lastPostion, myCoord) = locationData { - let nodeCoord = CLLocation(latitude: lastPostion.nodeCoordinate!.latitude, longitude: lastPostion.nodeCoordinate!.longitude) - let metersAway = nodeCoord.distance(from: myCoord) - Image(systemName: "lines.measurement.horizontal") - .font(.callout) - .symbolRenderingMode(.multicolor) - .frame(width: 30) - DistanceText(meters: metersAway) - .font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption) - .foregroundColor(.gray) - let trueBearing = getBearingBetweenTwoPoints(point1: myCoord, point2: nodeCoord) - let headingDegrees = Measurement(value: trueBearing, unit: UnitAngle.degrees) - Image(systemName: "location.north") - .font(.callout) - .symbolRenderingMode(.multicolor) - .clipShape(Circle()) - .rotationEffect(Angle(degrees: headingDegrees.value)) - let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees) - Text("\(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))") - .font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption) - .foregroundColor(.gray) - } - } - } - HStack { - if node.channel > 0 { - IconAndText(systemName: "\(node.channel).circle.fill", text: "Channel") - } - - if node.viaMqtt && connectedNode != node.num { - IconAndText(systemName: "dot.radiowaves.up.forward", - renderingMode: .multicolor, - text: "MQTT") - } - } - if node.hasPositions || node.hasEnvironmentMetrics || node.hasDetectionSensorMetrics || node.hasTraceRoutes { - HStack { - IconAndText(systemName: "scroll", text: "Logs:") - if node.hasDeviceMetrics { - DefaultIcon(systemName: "flipphone") - } - if node.hasPositions { - DefaultIcon(systemName: "mappin.and.ellipse") - } - if node.hasEnvironmentMetrics { - DefaultIcon(systemName: "cloud.sun.rain") - } - if node.hasDetectionSensorMetrics { - DefaultIcon(systemName: "sensor") - } - if node.hasTraceRoutes { - DefaultIcon(systemName: "signpost.right.and.left") - } - } - } - if node.hopsAway > 0 { - HStack { - IconAndText(systemName: "hare", text: "Hops Away:") - Image(systemName: "\(node.hopsAway).square") - .font(.title2) - } - } else { - if node.snr != 0 && !node.viaMqtt { - LoRaSignalStrengthMeter(snr: node.snr, rssi: node.rssi, preset: modemPreset, compact: true) - .padding(.top, node.hasPositions || node.hasEnvironmentMetrics || node.hasDetectionSensorMetrics || node.hasTraceRoutes ? 0 : 15) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) } + VStack(alignment: .leading) { + HStack { + let (image, color) = userKeyStatus + IconAndText(systemName: image, + imageColor: color, + text: node.user?.longName?.addingVariationSelectors ?? "Unknown".localized, + textColor: .primary) + if node.favorite { + Spacer() + Image(systemName: "star.fill") + .symbolRenderingMode(.multicolor) + } + } + if isDirectlyConnected { + IconAndText(systemName: "antenna.radiowaves.left.and.right.circle.fill", + imageColor: .green, + text: "Connected".localized) + } + if node.lastHeard?.timeIntervalSince1970 ?? 0 > 0 && node.lastHeard! < Calendar.current.date(byAdding: .year, value: 1, to: Date())! { + IconAndText(systemName: node.isOnline ? "checkmark.circle.fill" : "moon.circle.fill", + imageColor: node.isOnline ? .green : .orange, + text: node.lastHeard?.formatted() ?? "Unknown Age".localized) + } + let role = DeviceRoles(rawValue: Int(node.user?.role ?? 0)) + IconAndText(systemName: role?.systemName ?? "figure", + text: "Role: \(role?.name ?? "Unknown".localized)") + if node.user?.unmessagable ?? false { + IconAndText(systemName: "iphone.slash", + renderingMode: .multicolor, + text: "Unmonitored") + } + if node.isStoreForwardRouter { + IconAndText(systemName: "envelope.arrow.triangle.branch", + renderingMode: .multicolor, + text: "Store & Forward".localized) + } + + if node.positions?.count ?? 0 > 0 && connectedNode != node.num { + HStack { + if let (lastPostion, myCoord) = locationData { + let nodeCoord = CLLocation(latitude: lastPostion.nodeCoordinate!.latitude, longitude: lastPostion.nodeCoordinate!.longitude) + let metersAway = nodeCoord.distance(from: myCoord) + Image(systemName: "lines.measurement.horizontal") + .font(.callout) + .symbolRenderingMode(.multicolor) + .frame(width: 30) + DistanceText(meters: metersAway) + .font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption) + .foregroundColor(.gray) + let trueBearing = getBearingBetweenTwoPoints(point1: myCoord, point2: nodeCoord) + let headingDegrees = Measurement(value: trueBearing, unit: UnitAngle.degrees) + Image(systemName: "location.north") + .font(.callout) + .symbolRenderingMode(.multicolor) + .clipShape(Circle()) + .rotationEffect(Angle(degrees: headingDegrees.value)) + let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees) + Text("\(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))") + .font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption) + .foregroundColor(.gray) + } + } + } + HStack { + if node.channel > 0 { + IconAndText(systemName: "\(node.channel).circle.fill", text: "Channel") + } + + if node.viaMqtt && connectedNode != node.num { + IconAndText(systemName: "dot.radiowaves.up.forward", + renderingMode: .multicolor, + text: "MQTT") + } + } + if node.hasPositions || node.hasEnvironmentMetrics || node.hasDetectionSensorMetrics || node.hasTraceRoutes { + HStack { + IconAndText(systemName: "scroll", text: "Logs:") + if node.hasDeviceMetrics { + DefaultIcon(systemName: "flipphone") + } + if node.hasPositions { + DefaultIcon(systemName: "mappin.and.ellipse") + } + if node.hasEnvironmentMetrics { + DefaultIcon(systemName: "cloud.sun.rain") + } + if node.hasDetectionSensorMetrics { + DefaultIcon(systemName: "sensor") + } + if node.hasTraceRoutes { + DefaultIcon(systemName: "signpost.right.and.left") + } + } + } + if node.hopsAway > 0 { + HStack { + IconAndText(systemName: "hare", text: "Hops Away:") + Image(systemName: "\(node.hopsAway).square") + .font(.title2) + } + } else { + if node.snr != 0 && !node.viaMqtt { + LoRaSignalStrengthMeter(snr: node.snr, rssi: node.rssi, preset: modemPreset, compact: true) + .padding(.top, node.hasPositions || node.hasEnvironmentMetrics || node.hasDetectionSensorMetrics || node.hasTraceRoutes ? 0 : 15) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.top, 4) .padding(.bottom, 4) - // Accessibility: Make the whole row a single element for VoiceOver - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityDescription) - } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + } } struct DefaultIcon: View { let systemName: String - + var body: some View { Image(systemName: systemName) .symbolRenderingMode(.hierarchical) @@ -281,7 +268,7 @@ struct IconAndText: View { var renderingMode: SymbolRenderingMode = .hierarchical let text: String var textColor: Color = .gray - + @ViewBuilder var image: some View { if let color = imageColor { @@ -291,7 +278,7 @@ struct IconAndText: View { Image(systemName: systemName) } } - + var body: some View { HStack { image diff --git a/Meshtastic/Views/Nodes/Helpers/PreferenceKeys/TileHeightKeys.swift b/Meshtastic/Views/Nodes/Helpers/PreferenceKeys/TileHeightKeys.swift new file mode 100644 index 00000000..0225ce13 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/PreferenceKeys/TileHeightKeys.swift @@ -0,0 +1,26 @@ +// +// TileHeightKeys.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 9/21/25. +// + +import SwiftUI + +struct WeatherKitTilesHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + // This method combines values from multiple child views if needed + value = max(value, nextValue()) + } +} + +struct EnvironmentMetricsTilesHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + // This method combines values from multiple child views if needed + value = max(value, nextValue()) + } +} diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 6eff0e17..f781a411 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -1,23 +1,19 @@ // -// NodeListSplit.swift -// Meshtastic +//  NodeList.swift +//  Meshtastic // -// Created by Garth Vander Houwen on 9/8/23. +//  Copyright(c) Garth Vander Houwen 9/8/23. // import SwiftUI import CoreLocation import OSLog import CoreData +import Foundation struct NodeList: View { - @Environment(\.managedObjectContext) - var context - + @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager - @StateObject var router: Router - - @State private var columnVisibility = NavigationSplitViewVisibility.all @State private var selectedNode: NodeInfoEntity? @State private var isPresentingTraceRouteSentAlert = false @State private var isPresentingPositionSentAlert = false @@ -26,9 +22,7 @@ struct NodeList: View { @State private var deleteNodeId: Int64 = 0 @State private var shareContactNode: NodeInfoEntity? @StateObject var filters = NodeFilterParameters() - @State var isEditingFilters = false - @SceneStorage("selectedDetailView") var selectedDetailView: String? var connectedNode: NodeInfoEntity? { @@ -38,7 +32,144 @@ struct NodeList: View { return nil } - private func fetchNodes(withFilters: NodeFilterParameters) -> [NodeInfoEntity] { + var body: some View { + NavigationSplitView { + FilteredNodeList( + withFilters: filters, + selectedNode: $selectedNode, + connectedNode: connectedNode, + isPresentingDeleteNodeAlert: $isPresentingDeleteNodeAlert, + deleteNodeId: $deleteNodeId, + shareContactNode: $shareContactNode + ) + .sheet(isPresented: $isEditingFilters) { + NodeListFilter( + filters: filters + ) + } + .safeAreaInset(edge: .bottom, alignment: .trailing) { + HStack { + Button(action: { + withAnimation { + isEditingFilters = !isEditingFilters + } + }) { + Image(systemName: !isEditingFilters ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + .controlSize(.regular) + .padding(5) + } + .searchable(text: $filters.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a node") + .autocorrectionDisabled(true) + .scrollDismissesKeyboard(.immediately) + .navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(getNodeCount()))) + .listStyle(.plain) + .alert("Position Exchange Requested", isPresented: $isPresentingPositionSentAlert) { + Button("OK") { }.keyboardShortcut(.defaultAction) + } message: { + Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.") + } + .alert("Position Exchange Failed", isPresented: $isPresentingPositionFailedAlert) { + Button("OK") { }.keyboardShortcut(.defaultAction) + } message: { + Text("Failed to get a valid position to exchange") + } + .alert("Trace Route Sent", isPresented: $isPresentingTraceRouteSentAlert) { + Button("OK") { }.keyboardShortcut(.defaultAction) + } message: { + Text("This could take a while, response will appear in the trace route log for the node it was sent to.") + } + .confirmationDialog("Are you sure?", isPresented: $isPresentingDeleteNodeAlert, titleVisibility: .visible) { + Button("Delete Node", role: .destructive) { + let deleteNode = getNodeInfo(id: deleteNodeId, context: context) + if connectedNode != nil { + if let node = deleteNode { + Task { + do { + try await accessoryManager.removeNode(node: node, connectedNodeNum: Int64(accessoryManager.activeDeviceNum ?? -1)) + } catch { + Logger.data.error("Failed to delete node \(node.user?.longName ?? "Unknown".localized, privacy: .public)") + } + } + } + } + } + } + .sheet(item: $shareContactNode) { selectedNode in + ShareContactQRDialog(node: selectedNode.toProto()) + } + .navigationSplitViewColumnWidth(min: 100, ideal: 300, max: .infinity) + .navigationBarItems(leading: MeshtasticLogo(), trailing: ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?", + phoneOnly: true + ) + } + .accessibilityElement(children: .contain)) + } detail: { + if let node = selectedNode { + NodeDetail( + connectedNode: connectedNode, + node: node + ) + } else { + ContentUnavailableView("Select a Node", systemImage: "flipphone") + } + } + .onChange(of: router.navigationState.nodeListSelectedNodeNum) { _, newNum in + if let num = newNum { + self.selectedNode = getNodeInfo(id: num, context: context) + } else { + self.selectedNode = nil + } + } + .onChange(of: selectedNode) { _, node in + if let num = node?.num { + router.navigationState.nodeListSelectedNodeNum = num + } else { + router.navigationState.nodeListSelectedNodeNum = nil + } + } + } + + // Helper to get the count of nodes for the navigation title + private func getNodeCount() -> Int { + let request: NSFetchRequest = NodeInfoEntity.fetchRequest() + request.predicate = filters.buildPredicate() + return (try? context.count(for: request)) ?? 0 + } +} + +// +//  FilteredNodeList.swift +//  Meshtastic +// +fileprivate struct FilteredNodeList: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @FetchRequest private var nodes: FetchedResults + @Environment(\.managedObjectContext) var context + + @Binding var selectedNode: NodeInfoEntity? + var connectedNode: NodeInfoEntity? + @Binding var isPresentingDeleteNodeAlert: Bool + @Binding var deleteNodeId: Int64 + @Binding var shareContactNode: NodeInfoEntity? + + // The initializer for the FetchRequest + init( + withFilters: NodeFilterParameters, + selectedNode: Binding, + connectedNode: NodeInfoEntity?, + isPresentingDeleteNodeAlert: Binding, + deleteNodeId: Binding, + shareContactNode: Binding + ) { let request: NSFetchRequest = NodeInfoEntity.fetchRequest() request.sortDescriptors = [ NSSortDescriptor(key: "ignored", ascending: true), @@ -47,7 +178,32 @@ struct NodeList: View { NSSortDescriptor(key: "user.longName", ascending: true) ] request.predicate = withFilters.buildPredicate() - return (try? context.fetch(request)) ?? [] + self._nodes = FetchRequest(fetchRequest: request) + + self._selectedNode = selectedNode + self.connectedNode = connectedNode + self._isPresentingDeleteNodeAlert = isPresentingDeleteNodeAlert + self._deleteNodeId = deleteNodeId + self._shareContactNode = shareContactNode + } + + // The body of the view + var body: some View { + List(nodes, id: \.self, selection: $selectedNode) { node in + NavigationLink(value: node) { + NodeListItem( + node: node, + isDirectlyConnected: node.num == accessoryManager.activeDeviceNum, + connectedNode: accessoryManager.activeConnection?.device.num ?? -1 + ) + } + .contextMenu { + contextMenuActions( + node: node, + connectedNode: connectedNode + ) + } + } } @ViewBuilder @@ -55,7 +211,6 @@ struct NodeList: View { node: NodeInfoEntity, connectedNode: NodeInfoEntity? ) -> some View { - /// Allow users to mute notifications for a node even if they are not connected if let user = node.user { NodeAlertsButton(context: context, node: node, user: user) if !user.unmessagable && user.num == UserDefaults.preferredPeripheralNum { @@ -67,9 +222,7 @@ struct NodeList: View { } } if let connectedNode { - /// Favoriting a node requires being connected FavoriteNodeButton(node: node) - /// Don't show message, trace route, position exchange or delete context menu items for the connected node if connectedNode.num != node.num { if !(node.user?.unmessagable ?? true) { Button(action: { @@ -89,10 +242,7 @@ struct NodeList: View { wantResponse: true ) Task { @MainActor in - isPresentingPositionSentAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingPositionSentAlert = false - } + // Update state to show alert } } catch { Logger.mesh.warning("Failed to sendPosition") @@ -116,178 +266,18 @@ struct NodeList: View { } } } - - var body: some View { - let nodes = fetchNodes(withFilters: filters) - NavigationSplitView(columnVisibility: $columnVisibility) { - List(nodes, id: \.self, selection: $selectedNode) { node in - NodeListItem( - node: node, - isDirectlyConnected: node.num == accessoryManager.activeDeviceNum, - connectedNode: accessoryManager.activeConnection?.device.num ?? -1 - ) - .contextMenu { - contextMenuActions( - node: node, - connectedNode: connectedNode - ) - } - } - .sheet(isPresented: $isEditingFilters) { - NodeListFilter( - filters: filters - ) - } - .safeAreaInset(edge: .bottom, alignment: .trailing) { - HStack { - Button(action: { - withAnimation { - isEditingFilters = !isEditingFilters - } - }) { - Image(systemName: !isEditingFilters ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - } - .controlSize(.regular) - .padding(5) - } - .searchable(text: $filters.searchText, placement: .automatic, prompt: "Find a node") - .disableAutocorrection(true) - .scrollDismissesKeyboard(.immediately) - .navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(nodes.count))) - .listStyle(.plain) - .alert( - "Position Exchange Requested", - isPresented: $isPresentingPositionSentAlert) { - Button("OK") { }.keyboardShortcut(.defaultAction) - } message: { - Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.") - } - .alert( - "Position Exchange Failed", - isPresented: $isPresentingPositionFailedAlert) { - Button("OK") { }.keyboardShortcut(.defaultAction) - } message: { - Text("Failed to get a valid position to exchange") - } - .alert( - "Trace Route Sent", - isPresented: $isPresentingTraceRouteSentAlert) { - Button("OK") { }.keyboardShortcut(.defaultAction) - } message: { - Text("This could take a while, response will appear in the trace route log for the node it was sent to.") - } - .confirmationDialog( - "Are you sure?", - isPresented: $isPresentingDeleteNodeAlert, - titleVisibility: .visible - ) { - Button("Delete Node") { - let deleteNode = getNodeInfo(id: deleteNodeId, context: context) - if connectedNode != nil { - if deleteNode != nil { - Task { - do { - try await accessoryManager.removeNode(node: deleteNode!, connectedNodeNum: Int64(accessoryManager.activeDeviceNum ?? -1)) - } catch { - Logger.data.error("Failed to delete node \(deleteNode?.user?.longName ?? "Unknown".localized, privacy: .public)") - } - } - } - } - } - } - .sheet(item: $shareContactNode) { selectedNode in - ShareContactQRDialog(node: selectedNode.toProto()) - } - .navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500) - .navigationBarItems( - leading: MeshtasticLogo(), - trailing: ZStack { - ConnectedDevice( - deviceConnected: accessoryManager.isConnected, - name: accessoryManager.activeConnection?.device.shortName ?? "?", - phoneOnly: true - ) - } - // Make sure the ZStack passes through accessibility to the ConnectedDevice component - .accessibilityElement(children: .contain) - ) - } content: { - if let node = selectedNode { - NavigationStack { - NodeDetail( - connectedNode: connectedNode, - node: node, - columnVisibility: columnVisibility - ) - .edgesIgnoringSafeArea([.leading, .trailing]) - .navigationBarItems( - trailing: ZStack { - ConnectedDevice( - deviceConnected: accessoryManager.isConnected, - name: accessoryManager.activeConnection?.device.shortName ?? "?", - phoneOnly: true - ) - } - // Make sure the ZStack passes through accessibility to the ConnectedDevice component - .accessibilityElement(children: .contain) - ) - } - } else { - ContentUnavailableView("Select Node", systemImage: "flipphone") - } - } detail: { - ContentUnavailableView("", systemImage: "line.3.horizontal") - } - .navigationSplitViewStyle(.balanced) - .onChange(of: selectedNode) { - if selectedNode != nil { - columnVisibility = .doubleColumn - } else { - columnVisibility = .all - router.navigationState.nodeListSelectedNodeNum = nil - } - } - .onChange(of: router.navigationState) { - if let selected = router.navigationState.nodeListSelectedNodeNum { - // First clear selection - 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) { - self.selectedNode = getNodeInfo(id: selected, context: context) - Logger.services.info("👷‍♂️ [App] 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.selectedNode = getNodeInfo(id: nodeNum, context: self.context) - Logger.services.info("NodeList directly updated from notification for node: \(nodeNum, privacy: .public)") - } - } - } - .onDisappear { - // Remove observer when view disappears - NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil) - } - } } +// +//  NodeFilterParameters+Predicate.swift +//  Meshtastic +// + fileprivate extension NodeFilterParameters { func buildPredicate() -> NSPredicate? { var predicates: [NSPredicate] = [] - // (same predicate logic you have, but organized in functions) + // Search text predicates if !searchText.isEmpty { let searchKeys = [ "user.userId", "user.numString", "user.hwModel", @@ -299,67 +289,57 @@ fileprivate extension NodeFilterParameters { predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: textPredicates)) } + // Favorite filter if isFavorite { predicates.append(NSPredicate(format: "favorite == YES")) } - if !(viaLora && viaMqtt) { - if viaLora { - let loraPredicate = NSPredicate(format: "viaMqtt == NO") - predicates.append(loraPredicate) - } else { - let mqttPredicate = NSPredicate(format: "viaMqtt == YES") - predicates.append(mqttPredicate) - } + // Via Lora/MQTT filters + if viaLora && !viaMqtt { + predicates.append(NSPredicate(format: "viaMqtt == NO")) + } else if !viaLora && viaMqtt { + predicates.append(NSPredicate(format: "viaMqtt == YES")) } - /// Role - if roleFilter && deviceRoles.count > 0 { - var rolesArray: [NSPredicate] = [] - for dr in deviceRoles { - let deviceRolePredicate = NSPredicate(format: "user.role == %i", Int32(dr)) - rolesArray.append(deviceRolePredicate) + // Role filter + if roleFilter && !deviceRoles.isEmpty { + let rolesPredicates = deviceRoles.map { + NSPredicate(format: "user.role == %i", Int32($0)) } - let compoundPredicate = NSCompoundPredicate(type: .or, subpredicates: rolesArray) - predicates.append(compoundPredicate) + predicates.append(NSCompoundPredicate(type: .or, subpredicates: rolesPredicates)) } - /// Hops Away + + // Hops Away filter if hopsAway == 0.0 { - let hopsAwayPredicate = NSPredicate(format: "hopsAway == %i", Int32(hopsAway)) - predicates.append(hopsAwayPredicate) - } else if hopsAway > -1.0 { - let hopsAwayPredicate = NSPredicate(format: "hopsAway > 0 AND hopsAway <= %i", Int32(hopsAway)) - predicates.append(hopsAwayPredicate) + predicates.append(NSPredicate(format: "hopsAway == %i", 0)) + } else if hopsAway > 0.0 { + predicates.append(NSPredicate(format: "hopsAway > 0 AND hopsAway <= %i", Int32(hopsAway))) } - /// Online + + // Online filter if isOnline { let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate) predicates.append(isOnlinePredicate) } - /// Encrypted + + // Encrypted filter if isPkiEncrypted { - let isPkiEncryptedPredicate = NSPredicate(format: "user.pkiEncrypted == YES") - predicates.append(isPkiEncryptedPredicate) + predicates.append(NSPredicate(format: "user.pkiEncrypted == YES")) } - /// Favorites - if isFavorite { - let isFavoritePredicate = NSPredicate(format: "favorite == YES") - predicates.append(isFavoritePredicate) - } - /// Ignored + + // Ignored filter if isIgnored { - let isIgnoredPredicate = NSPredicate(format: "ignored == YES") - predicates.append(isIgnoredPredicate) - } else if !isIgnored { - let isIgnoredPredicate = NSPredicate(format: "ignored == NO") - predicates.append(isIgnoredPredicate) + predicates.append(NSPredicate(format: "ignored == YES")) + } else { + predicates.append(NSPredicate(format: "ignored == NO")) } - /// Environment + + // Environment filter if isEnvironment { - let environmentPredicate = NSPredicate(format: "SUBQUERY(telemetries, $tel, $tel.metricsType == 1).@count > 0") - predicates.append(environmentPredicate) + predicates.append(NSPredicate(format: "SUBQUERY(telemetries, $tel, $tel.metricsType == 1).@count > 0")) } - /// Distance + + // Distance filter if distanceFilter { let pointOfInterest = LocationsHandler.currentLocation @@ -378,7 +358,6 @@ fileprivate extension NodeFilterParameters { } } - return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + return predicates.isEmpty ? nil : NSCompoundPredicate(type: .and, subpredicates: predicates) } } - diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index 5fbef989..e8a54ce9 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -276,7 +276,7 @@ struct DeviceOnboarding: View { makeRow( icon: "person.and.background.dotted", title: "Background Connections".localized, - subtitle: "Bluetooth Low Energy supports background connections. When possible, the applicaiton will remain connected to these accessories while the app is in the background".localized + subtitle: "Bluetooth Low Energy supports background connections. When possible, the application will remain connected to these accessories while the app is in the background.".localized ) } .padding() diff --git a/Meshtastic/Views/Settings/Channels/ChannelForm.swift b/Meshtastic/Views/Settings/Channels/ChannelForm.swift index c463b733..3579b938 100644 --- a/Meshtastic/Views/Settings/Channels/ChannelForm.swift +++ b/Meshtastic/Views/Settings/Channels/ChannelForm.swift @@ -25,10 +25,9 @@ struct ChannelForm: View { @Binding var supportedVersion: Bool var body: some View { - NavigationStack { Form { - Section(header: Text("channel details")) { + Section(header: Text("Channel Details")) { HStack { Text("Name") Spacer() diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 280aee53..0c61344b 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -177,7 +177,6 @@ struct SecurityConfig: View { Text("The primary public key authorized to send admin messages to this node.") .foregroundStyle(.secondary) .font(idiom == .phone ? .caption : .callout) - Divider() Label("Secondary Admin Key", systemImage: "key.viewfinder") SecureInput("Secondary Admin Key", text: $adminKey2, isValid: $hasValidAdminKey2) .background( @@ -187,7 +186,6 @@ struct SecurityConfig: View { Text("The secondary public key authorized to send admin messages to this node.") .foregroundStyle(.secondary) .font(idiom == .phone ? .caption : .callout) - Divider() Label("Tertiary Admin Key", systemImage: "key.viewfinder") SecureInput("Tertiary Admin Key", text: $adminKey3, isValid: $hasValidAdminKey3) .background( diff --git a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift index 179f8a4c..52927149 100644 --- a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift +++ b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift @@ -163,7 +163,7 @@ struct AppLogFilter: View { .padding(.bottom) #endif } - .presentationDetents([.medium, .large], selection: $currentDetent) + .presentationDetents([.large], selection: $currentDetent) .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled(upThrough: .medium))