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 <jake-b@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Garth Vander Houwen 2025-09-24 20:55:42 -07:00 committed by GitHub
parent 932e5a393e
commit 6bb880d503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 973 additions and 841 deletions

View file

@ -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" : {

View file

@ -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 = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = PreferenceKeys; sourceTree = "<group>"; };
/* 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)";

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DDC2E15326CE248E0042C5E4"
BuildableName = "Meshtastic.app"
BlueprintName = "Meshtastic"
ReferencedContainer = "container:Meshtastic.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "1"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DDC2E15326CE248E0042C5E4"
BuildableName = "Meshtastic.app"
BlueprintName = "Meshtastic"
ReferencedContainer = "container:Meshtastic.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DDC2E15326CE248E0042C5E4"
BuildableName = "Meshtastic.app"
BlueprintName = "Meshtastic"
ReferencedContainer = "container:Meshtastic.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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 {
}
}
}

View file

@ -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 {

View file

@ -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<DiscoveryEvent>.Continuation?
private let delegate: BLEDelegate
private var connectingPeripheral: CBPeripheral?
private var activeConnection: BLEConnection?
private var connectContinuation: CheckedContinuation<BLEConnection, Error>?
private var setupCompleteContinuation: CheckedContinuation<Void, Error>?
private var restoredConnectContinuation: CheckedContinuation<Void, Error>?
private var setupCompleteGate: AsyncGate
private var restoreInProgress: Bool = false
var status: TransportStatus = .uninitialized
private var cleanupTask: Task<Void, Never>?
@ -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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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> = 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<UserEntity>
@Binding var userSelection: UserEntity?
@Binding var node: NodeInfoEntity?
@State private var isPresentingDeleteUserMessagesConfirm: Bool = false
@State private var userToDeleteMessages: UserEntity?
init(withFilters: NodeFilterParameters, node: Binding<NodeInfoEntity?>, userSelection: Binding<UserEntity?>) {
let request: NSFetchRequest<UserEntity> = 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] = []

View file

@ -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 {

View file

@ -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)
}
}
}
}
}

View file

@ -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))

View file

@ -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")

View file

@ -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))
}
}
}

View file

@ -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))

View file

@ -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

View file

@ -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())
}
}

View file

@ -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> = 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<NodeInfoEntity>
@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<NodeInfoEntity?>,
connectedNode: NodeInfoEntity?,
isPresentingDeleteNodeAlert: Binding<Bool>,
deleteNodeId: Binding<Int64>,
shareContactNode: Binding<NodeInfoEntity?>
) {
let request: NSFetchRequest<NodeInfoEntity> = 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)
}
}

View file

@ -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()

View file

@ -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()

View file

@ -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(

View file

@ -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))