mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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:
parent
932e5a393e
commit
6bb880d503
24 changed files with 973 additions and 841 deletions
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue