diff --git a/Localizable.xcstrings b/Localizable.xcstrings index fd5400d29..fda8f5f7d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -18819,6 +18819,7 @@ } }, "select.menu.item" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index f51a741ef..48b599030 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -21,6 +21,10 @@ 25AECD4F2C2F723200862C8E /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 25AECD4E2C2F723200862C8E /* Localizable.xcstrings */; }; 25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0C2C285F00007E03CA /* Logger.swift */; }; 25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB152C28B1E4007E03CA /* AppData.swift */; }; + 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */; }; + 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; + 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; + 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; @@ -204,6 +208,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 25F5D5CB2C4375A8008036E3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DDC2E14C26CE248E0042C5E4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DDC2E15326CE248E0042C5E4; + remoteInfo = Meshtastic; + }; DDDE5A0129AF163E00490C6C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DDC2E14C26CE248E0042C5E4 /* Project object */; @@ -235,6 +246,11 @@ 2519268F2C3CB44900249DF5 /* ClientHistoryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientHistoryButton.swift; sourceTree = ""; }; 251926912C3CB52300249DF5 /* DeleteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNodeButton.swift; sourceTree = ""; }; 25AECD4E2C2F723200862C8E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = ""; }; + 25F5D5BF2C3F6DA6008036E3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; @@ -461,6 +477,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 25F5D5C42C4375A8008036E3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDC2E15126CE248E0042C5E4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -497,6 +520,23 @@ path = Actions; sourceTree = ""; }; + 25F5D5BC2C3F6D7B008036E3 /* Router */ = { + isa = PBXGroup; + children = ( + 25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */, + 25F5D5BF2C3F6DA6008036E3 /* Router.swift */, + ); + path = Router; + sourceTree = ""; + }; + 25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = { + isa = PBXGroup; + children = ( + 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, + ); + path = MeshtasticTests; + sourceTree = ""; + }; C9483F6B2773016700998F6B /* MapKitMap */ = { isa = PBXGroup; children = ( @@ -735,6 +775,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */, DDC2E15626CE248E0042C5E4 /* Meshtastic */, DDDE59F729AF163D00490C6C /* Widgets */, + 25F5D5C82C4375A8008036E3 /* MeshtasticTests */, DDC2E15526CE248E0042C5E4 /* Products */, DD8EDE9226F97A2B00A5A10B /* Frameworks */, ); @@ -746,6 +787,7 @@ children = ( DDC2E15426CE248E0042C5E4 /* Meshtastic.app */, DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */, + 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */, ); name = Products; sourceTree = ""; @@ -753,6 +795,7 @@ DDC2E15626CE248E0042C5E4 /* Meshtastic */ = { isa = PBXGroup; children = ( + 25F5D5BC2C3F6D7B008036E3 /* Router */, DD7709392AA1ABA1007A8BF0 /* Tips */, DD90860A26F645B700DC5189 /* Meshtastic.entitlements */, DD8ED9C6289CE4A100B3B0AB /* Enums */, @@ -764,6 +807,7 @@ DDC2E18926CE24F70042C5E4 /* Resources */, DDC2E18726CE24E40042C5E4 /* Views */, DDC2E15726CE248E0042C5E4 /* MeshtasticApp.swift */, + 25F5D5C12C3F6E4B008036E3 /* AppState.swift */, DDC2E16526CE248F0042C5E4 /* Info.plist */, DDC2E15D26CE248F0042C5E4 /* Preview Content */, 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */, @@ -958,6 +1002,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 25F5D5C62C4375A8008036E3 /* MeshtasticTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 25F5D5CF2C4375A8008036E3 /* Build configuration list for PBXNativeTarget "MeshtasticTests" */; + buildPhases = ( + 25F5D5C32C4375A8008036E3 /* Sources */, + 25F5D5C42C4375A8008036E3 /* Frameworks */, + 25F5D5C52C4375A8008036E3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 25F5D5CC2C4375A8008036E3 /* PBXTargetDependency */, + ); + name = MeshtasticTests; + productName = MeshtasticTests; + productReference = 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; DDC2E15326CE248E0042C5E4 /* Meshtastic */ = { isa = PBXNativeTarget; buildConfigurationList = DDC2E17E26CE248F0042C5E4 /* Build configuration list for PBXNativeTarget "Meshtastic" */; @@ -1010,9 +1072,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1540; TargetAttributes = { + 25F5D5C62C4375A8008036E3 = { + CreatedOnToolsVersion = 15.4; + TestTargetID = DDC2E15326CE248E0042C5E4; + }; DDC2E15326CE248E0042C5E4 = { CreatedOnToolsVersion = 12.5.1; LastSwiftMigration = 1340; @@ -1051,11 +1117,19 @@ targets = ( DDC2E15326CE248E0042C5E4 /* Meshtastic */, DDDE59F329AF163D00490C6C /* WidgetsExtension */, + 25F5D5C62C4375A8008036E3 /* MeshtasticTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 25F5D5C52C4375A8008036E3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDC2E15226CE248E0042C5E4 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1102,6 +1176,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 25F5D5C32C4375A8008036E3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDC2E15026CE248E0042C5E4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1155,6 +1237,7 @@ DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, + 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, @@ -1181,6 +1264,7 @@ DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */, DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */, DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */, + 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */, DDDB444A29F8AA3A00EE2349 /* CLLocationCoordinate2D.swift in Sources */, DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */, DDF45C372BC46A5A005ED5F2 /* TimeZone.swift in Sources */, @@ -1259,6 +1343,7 @@ DDB6CCFB2AAF805100945AF6 /* NodeMapSwiftUI.swift in Sources */, DD73FD1128750779000852D6 /* PositionLog.swift in Sources */, DD15E4F52B8BFC8E00654F61 /* PaxCounterLog.swift in Sources */, + 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */, DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */, DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */, DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */, @@ -1298,6 +1383,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 25F5D5CC2C4375A8008036E3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DDC2E15326CE248E0042C5E4 /* Meshtastic */; + targetProxy = 25F5D5CB2C4375A8008036E3 /* PBXContainerItemProxy */; + }; DDDE5A0229AF163E00490C6C /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; @@ -1307,6 +1397,52 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 25F5D5CD2C4375A8008036E3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GCH7VS5Y9R; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Meshtastic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Meshtastic"; + }; + name = Debug; + }; + 25F5D5CE2C4375A8008036E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GCH7VS5Y9R; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Meshtastic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Meshtastic"; + }; + name = Release; + }; DDC2E17C26CE248F0042C5E4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1569,6 +1705,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 25F5D5CF2C4375A8008036E3 /* Build configuration list for PBXNativeTarget "MeshtasticTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 25F5D5CD2C4375A8008036E3 /* Debug */, + 25F5D5CE2C4375A8008036E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DDC2E14F26CE248E0042C5E4 /* Build configuration list for PBXProject "Meshtastic" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Meshtastic/AppState.swift b/Meshtastic/AppState.swift new file mode 100644 index 000000000..72f0d23a8 --- /dev/null +++ b/Meshtastic/AppState.swift @@ -0,0 +1,33 @@ +import Combine +import SwiftUI + +class AppState: ObservableObject { + @Published + var router: Router + + @Published + var unreadChannelMessages: Int + + @Published + var unreadDirectMessages: Int + + var totalUnreadMessages: Int { + unreadChannelMessages + unreadDirectMessages + } + + private var cancellables: Set = [] + + init(router: Router) { + self.router = router + self.unreadChannelMessages = 0 + self.unreadDirectMessages = 0 + + // Keep app icon badge count in sync with messages read status + $unreadChannelMessages.combineLatest($unreadDirectMessages) + .sink(receiveValue: { badgeCounts in + UNUserNotificationCenter.current() + .setBadgeCount(badgeCounts.0 + badgeCounts.1) + }) + .store(in: &cancellables) + } +} diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index f15ba0401..7d50b39bc 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -12,10 +12,12 @@ import OSLog // --------------------------------------------------------------------------------------- class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate, ObservableObject { + var appState: AppState + var context: NSManagedObjectContext? - static let shared = BLEManager() private var centralManager: CBCentralManager! + @Published var peripherals: [Peripheral] = [] @Published var connectedPeripheral: Peripheral! @Published var lastConnectionError: String @@ -24,7 +26,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate @Published var automaticallyReconnect: Bool = true @Published var mqttProxyConnected: Bool = false @Published var mqttError: String = "" - @StateObject var appState = AppState.shared public var minimumVersion = "2.0.0" public var connectedVersion: String public var isConnecting: Bool = false @@ -52,8 +53,12 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let LEGACY_LOGRADIO_UUID = CBUUID(string: "0x6C6FD238-78FA-436B-AACF-15C5BE1EF2E2") let LOGRADIO_UUID = CBUUID(string: "0x5a3d6e49-06e6-4423-9944-e9de8cdf9547") - // MARK: init BLEManager - override init() { + // MARK: init + + init( + appState: AppState + ) { + self.appState = appState self.lastConnectionError = "" self.connectedVersion = "0.0.0" super.init() @@ -238,7 +243,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate subtitle: "\(peripheral.name ?? "unknown".localized)", content: e.localizedDescription, target: "bluetooth", - path: "meshtastic://bluetooth" + path: "meshtastic:///bluetooth" ) ] manager.schedule() @@ -258,7 +263,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate subtitle: "\(peripheral.name ?? "unknown".localized)", content: e.localizedDescription, target: "bluetooth", - path: "meshtastic://bluetooth" + path: "meshtastic:///bluetooth" ) ] manager.schedule() @@ -726,7 +731,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let version = decodedInfo.metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: decodedInfo.metadata.firmwareVersion))] nowKnown = true connectedVersion = String(version.dropLast()) - appState.firmwareVersion = connectedVersion +// appState.firmwareVersion = connectedVersion UserDefaults.firmwareVersion = connectedVersion } let supportedVersion = connectedVersion == "0.0.0" || self.minimumVersion.compare(connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(connectedVersion, options: .numeric) == .orderedSame @@ -739,7 +744,13 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Log any other unknownApp calls if !nowKnown { MeshLogger.log("🕸️ MESH PACKET received for Unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") } case .textMessageApp, .detectionSensorApp: - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + textMessageAppPacket( + packet: decodedInfo.packet, + wantRangeTestPackets: wantRangeTestPackets, + connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), + context: context!, + appState: appState + ) case .remoteHardwareApp: MeshLogger.log("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") case .positionApp: @@ -754,7 +765,13 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate adminAppPacket(packet: decodedInfo.packet, context: context!) case .replyApp: MeshLogger.log("🕸️ MESH PACKET received for Reply App handling as a text message") - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + textMessageAppPacket( + packet: decodedInfo.packet, + wantRangeTestPackets: wantRangeTestPackets, + connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), + context: context!, + appState: appState + ) case .ipTunnelApp: // MeshLogger.log("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") MeshLogger.log("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED") @@ -769,7 +786,13 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } case .rangeTestApp: if wantRangeTestPackets { - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: true, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + textMessageAppPacket( + packet: decodedInfo.packet, + wantRangeTestPackets: true, + connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), + context: context!, + appState: appState + ) } else { MeshLogger.log("🕸️ MESH PACKET received for Range Test App Range testing is disabled.") } @@ -893,12 +916,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } } // Set initial unread message badge states - let appState = AppState.shared appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0 appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0 - // appState.connectedNode = fetchedNodeInfo[0] - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages - } if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true { wantRangeTestPackets = true @@ -1117,6 +1136,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return success } + @MainActor public func getPositionFromPhoneGPS(destNum: Int64) -> Position? { var positionPacket = Position() if #available(iOS 17.0, macOS 14.0, *) { @@ -1162,6 +1182,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return positionPacket } + @MainActor public func setFixedPosition(fromUser: UserEntity, channel: Int32) -> Bool { var adminPacket = AdminMessage() guard let positionPacket = getPositionFromPhoneGPS(destNum: fromUser.num) else { @@ -1216,6 +1237,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return false } + @MainActor public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) -> Bool { var success = false let fromNodeNum = connectedPeripheral.num @@ -1251,6 +1273,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } return success } + + @MainActor @objc func positionTimerFired(timer: Timer) { // Check for connected node if connectedPeripheral != nil { @@ -3055,10 +3079,24 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate MeshLogger.log("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())") case .routerTextDirect: MeshLogger.log("💬 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())") - textMessageAppPacket(packet: packet, wantRangeTestPackets: false, connectedNode: connectedNodeNum, storeForward: true, context: context) + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + context: context, + appState: appState + ) case .routerTextBroadcast: MeshLogger.log("✉️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())") - textMessageAppPacket(packet: packet, wantRangeTestPackets: false, connectedNode: connectedNodeNum, storeForward: true, context: context) + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + context: context, + appState: appState + ) } } } diff --git a/Meshtastic/Helpers/LocalNotificationManager.swift b/Meshtastic/Helpers/LocalNotificationManager.swift index d9ff996a4..3e41c8f39 100644 --- a/Meshtastic/Helpers/LocalNotificationManager.swift +++ b/Meshtastic/Helpers/LocalNotificationManager.swift @@ -40,18 +40,19 @@ class LocalNotificationManager { content.interruptionLevel = .timeSensitive if notification.target != nil { - content.userInfo["target"] = notification.target + content.userInfo["target"] = notification.target } if notification.path != nil { - content.userInfo["path"] = notification.path + content.userInfo["path"] = notification.path } let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in - - guard error == nil else { return } + if let error { + Logger.services.error("Error Scheduling Notification: \(error.localizedDescription)") + } } } } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 11da41453..23e19a334 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -706,7 +706,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", target: "nodes", - path: "meshtastic://nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" + path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" ) ] manager.schedule() @@ -740,7 +740,14 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage } } -func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connectedNode: Int64, storeForward: Bool = false, context: NSManagedObjectContext) { +func textMessageAppPacket( + packet: MeshPacket, + wantRangeTestPackets: Bool, + connectedNode: Int64, + storeForward: Bool = false, + context: NSManagedObjectContext, + appState: AppState +) { var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) let rangeRef = Reference(Int.self) @@ -821,12 +828,10 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { return } - let appState = AppState.shared if newMessage.fromUser != nil && newMessage.toUser != nil { // Set Unread Message Indicators if packet.to == connectedNode { appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0 - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } if !(newMessage.fromUser?.mute ?? false) { // Create an iOS Notification for the received DM message and schedule it immediately @@ -838,7 +843,7 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic://messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)" + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)" ) ] manager.schedule() @@ -853,7 +858,6 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) if !fetchedMyInfo.isEmpty { appState.unreadChannelMessages = fetchedMyInfo[0].unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { if channel.index == newMessage.channel { @@ -869,7 +873,7 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic://messages?channel=\(newMessage.channel)&messageId=\(newMessage.messageId)") + path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)") ] manager.schedule() Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") @@ -934,10 +938,10 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")", content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")", target: "map", - path: "meshtastic://map?waypontid=\(waypoint.id)" + path: "meshtastic:///map?waypointid=\(waypoint.id)" ) ] - Logger.data.debug("meshtastic://map?waypontid=\(waypoint.id)") + Logger.data.debug("meshtastic:///map?waypointid=\(waypoint.id)") manager.schedule() } catch { context.rollback() diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 578f16d41..38dde42a5 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -11,22 +11,44 @@ import TipKit @main struct MeshtasticAppleApp: App { - @UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self) var appDelegate - let persistenceController = PersistenceController.shared - @ObservedObject private var bleManager: BLEManager = BLEManager.shared + @UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self) + private var appDelegate - @Environment(\.scenePhase) var scenePhase + @ObservedObject + var appState: AppState + + @ObservedObject + private var bleManager: BLEManager + private let persistenceController: PersistenceController + + @Environment(\.scenePhase) var scenePhase @State var saveChannels = false @State var incomingUrl: URL? @State var channelSettings: String? @State var addChannels = false - @StateObject var appState = AppState.shared + + init() { + let persistenceController = PersistenceController.shared + let appState = AppState( + router: Router() + ) + self._appState = ObservedObject(wrappedValue: appState) + + self.bleManager = BLEManager(appState: appState) + self.persistenceController = persistenceController + + // Wire up router + self.appDelegate.router = appState.router + } var body: some Scene { WindowGroup { - ContentView() + ContentView( + appState: appState + ) .environment(\.managedObjectContext, persistenceController.container.viewContext) + .environmentObject(appState) .environmentObject(bleManager) .sheet(isPresented: $saveChannels) { SaveChannelQRCode(channelSetLink: channelSettings ?? "Empty Channel URL", addChannels: addChannels, bleManager: bleManager) @@ -34,14 +56,13 @@ struct MeshtasticAppleApp: App { .presentationDragIndicator(.visible) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in - Logger.mesh.debug("URL received \(userActivity)") self.incomingUrl = userActivity.webpageURL if (self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/#")) != nil { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false - if ((self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil) { + if (self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil { guard let cs = components.last!.components(separatedBy: "?").first else { return } @@ -83,15 +104,8 @@ struct MeshtasticAppleApp: App { } self.saveChannels = true Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link")") - } else if url.absoluteString.lowercased().contains("meshtastic://") { - appState.navigationPath = url.absoluteString - let path = appState.navigationPath ?? "" - if path.starts(with: "meshtastic://map") { - AppState.shared.tabSelection = Tab.map - } else if path.starts(with: "meshtastic://nodes") { - AppState.shared.tabSelection = Tab.nodes - } - + } else if url.absoluteString.lowercased().contains("meshtastic:///") { + appState.router.route(url: url) } else { saveChannels = false Logger.mesh.debug("User wants to import a MBTILES offline map file: \(self.incomingUrl?.absoluteString ?? "No Tiles link")") @@ -179,13 +193,3 @@ struct MeshtasticAppleApp: App { } } } - -class AppState: ObservableObject { - static let shared = AppState() - - @Published var tabSelection: Tab = .ble - @Published var unreadDirectMessages: Int = 0 - @Published var unreadChannelMessages: Int = 0 - @Published var firmwareVersion: String = "0.0.0" - @Published var navigationPath: String? -} diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 71f01d81c..06d290e99 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -9,6 +9,9 @@ import SwiftUI import OSLog class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, ObservableObject { + + var router: Router? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Logger.services.info("🚀 [App] Meshtstic Apple App launched!") // Default User Default Values @@ -28,23 +31,30 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat return true } // Lets us show the notification in the app in the foreground - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { completionHandler([.list, .banner, .sound]) } + // This method is called when a user clicks on the notification - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { let userInfo = response.notification.request.content.userInfo - let targetValue = userInfo["target"] as? String - let deepLink = userInfo["path"] as? String - - AppState.shared.navigationPath = deepLink - if targetValue == "map" { - AppState.shared.tabSelection = Tab.map - } else if targetValue == "messages" { - AppState.shared.tabSelection = Tab.messages - } else if targetValue == "nodes" { - AppState.shared.tabSelection = Tab.nodes + if let targetValue = userInfo["target"] as? String, + let deepLink = userInfo["path"] as? String, + let url = URL(string: deepLink) { + Logger.services.info("userNotificationCenter didReceiveResponse \(targetValue) \(deepLink)") + router?.route(url: url) + } else { + Logger.services.error("Failed to handle notification response: \(userInfo)") } + completionHandler() } } diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index cbf63ce50..e6605d484 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -188,7 +188,7 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) subtitle: "\(newUser.longName ?? "unknown".localized)", content: "New Node has been discovered", target: "nodes", - path: "meshtastic://nodes?nodenum=\(newUser.num)" + path: "meshtastic:///nodes?nodenum=\(newUser.num)" ) ] manager.schedule() diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift new file mode 100644 index 000000000..b33fc48a3 --- /dev/null +++ b/Meshtastic/Router/NavigationState.swift @@ -0,0 +1,106 @@ +import Foundation + +// MARK: Messages + +enum MessagesNavigationState: Hashable { + case channels( + channelId: Int32? = nil, + messageId: Int64? = nil + ) + case directMessages( + userNum: Int64? = nil, + messageId: Int64? = nil + ) +} + +// MARK: Map + +enum MapNavigationState: Hashable { + case selectedNode(Int64) + case waypoint(Int64) +} + +// MARK: Settings + +enum SettingsNavigationState: String { + case about + case appSettings + case routes + case routeRecorder + case lora + case channels + case shareQRCode + case user + case bluetooth + case device + case display + case network + case position + case power + case ambientLighting + case cannedMessages + case detectionSensor + case externalNotification + case mqtt + case rangeTest + case paxCounter + case ringtone + case serial + case storeAndForward + case telemetry + case meshLog + case debugLogs + case appFiles + case firmwareUpdates +} + +enum NavigationState: Hashable { + case messages(MessagesNavigationState? = nil) + case bluetooth + case nodes(selectedNodeNum: Int64? = nil) + case map(MapNavigationState? = nil) + case settings(SettingsNavigationState? = nil) +} + +// MARK: Tab Bar + +extension NavigationState { + enum Tab: String, Hashable { + case messages + case bluetooth + case nodes + case map + case settings + } + + var tab: Tab { + get { + switch self { + case .messages: + .messages + case .bluetooth: + .bluetooth + case .nodes: + .nodes + case .map: + .map + case .settings: + .settings + } + } + set { + self = switch newValue { + case .messages: + .messages() + case .bluetooth: + .bluetooth + case .nodes: + .nodes() + case .map: + .map() + case .settings: + .settings() + } + } + } +} diff --git a/Meshtastic/Router/Router.swift b/Meshtastic/Router/Router.swift new file mode 100644 index 000000000..4248e7bc3 --- /dev/null +++ b/Meshtastic/Router/Router.swift @@ -0,0 +1,109 @@ +import CoreData +import OSLog +import SwiftUI + +class Router: ObservableObject { + + @Published + var navigationState: NavigationState + + init( + navigationState: NavigationState = .bluetooth + ) { + self.navigationState = navigationState + } + + func route(to destination: NavigationState) { + navigationState = destination + } + + func route(url: URL) { + guard url.scheme == "meshtastic" else { + Logger.services.error("Received routing URL \(url) with invalid scheme. Ignoring route.") + return + } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + Logger.services.error("Received routing URL \(url) with invalid host path. Ignoring route.") + return + } + + if components.path == "/messages" { + routeMessages(components) + } else if components.path == "/bluetooth" { + route(to: .bluetooth) + } else if components.path == "/nodes" { + routeNodes(components) + } else if components.path == "/map" { + routeMap(components) + } else if components.path.hasPrefix("/settings") { + routeSettings(components) + } else { + Logger.services.warning("Failed to route url: \(url)") + } + } + + // MARK: Routing Helpers + + private func routeMessages( + _ components: URLComponents + ) { + let channelId = components.queryItems? + .first(where: { $0.name == "channelId" })? + .value + .flatMap(Int32.init) + let userNum = components.queryItems? + .first(where: { $0.name == "userNum" })? + .value + .flatMap(Int64.init) + let messageId = components.queryItems? + .first(where: { $0.name == "messageId" })? + .value + .flatMap(Int64.init) + + let state: MessagesNavigationState? = if let channelId { + .channels(channelId: channelId, messageId: messageId) + } else if let userNum { + .directMessages(userNum: userNum, messageId: messageId) + } else { + nil + } + route(to: .messages(state)) + } + + private func routeNodes(_ components: URLComponents) { + let nodeId = components.queryItems? + .first(where: { $0.name == "nodenum" })? + .value + .flatMap(Int64.init) + route(to: .nodes(selectedNodeNum: nodeId)) + } + + private func routeMap(_ components: URLComponents) { + let nodeId = components.queryItems? + .first(where: { $0.name == "nodenum" })? + .value + .flatMap(Int64.init) + let waypointId = components.queryItems? + .first(where: { $0.name == "waypointId" })? + .value + .flatMap(Int64.init) + if let nodeId { + route(to: .map(.selectedNode(nodeId))) + } else if let waypointId { + route(to: .map(.waypoint(waypointId))) + } else { + route(to: .map()) + } + } + + private func routeSettings(_ components: URLComponents) { + let settingFromPath = components.path + .split(separator: "/") + .dropFirst() + .first + .flatMap(String.init) + .flatMap(SettingsNavigationState.init(rawValue:)) + + route(to: .settings(settingFromPath)) + } +} diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index 46f7f903d..66b8a01b3 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -6,75 +6,69 @@ import SwiftUI @available(iOS 17.0, *) struct ContentView: View { + @ObservedObject + var appState: AppState + + private var tabBinding: Binding { + Binding( + get: { + appState.router.navigationState.tab + }, + set: { newValue in + appState.router.navigationState.tab = newValue + } + ) + } - @StateObject var appState = AppState.shared var body: some View { - TabView(selection: $appState.tabSelection) { - Messages() - .tabItem { - Label("messages", systemImage: "message") - } - .tag(Tab.contacts) - .badge(appState.unreadDirectMessages + appState.unreadChannelMessages) + TabView(selection: tabBinding) { + Messages( + router: appState.router, + unreadChannelMessages: $appState.unreadChannelMessages, + unreadDirectMessages: $appState.unreadDirectMessages + ) + .tabItem { + Label("messages", systemImage: "message") + } + .tag(NavigationState.Tab.messages) + .badge(appState.totalUnreadMessages) + Connect() .tabItem { Label("bluetooth", systemImage: "antenna.radiowaves.left.and.right") } - .tag(Tab.ble) - NodeList() - .tabItem { - Label("nodes", systemImage: "flipphone") - } - .tag(Tab.nodes) - if #available(iOS 17.0, macOS 14.0, *) { - if UserDefaults.mapUseLegacy { - NodeMap() - .tabItem { - Label("map", systemImage: "map") - } - .tag(Tab.map) - } else { - MeshMap() - .tabItem { - Label("map", systemImage: "map") - } - .tag(Tab.map) - } + .tag(NavigationState.Tab.bluetooth) + + NodeList( + router: appState.router + ) + .tabItem { + Label("nodes", systemImage: "flipphone") + } + .tag(NavigationState.Tab.nodes) + + if #available(iOS 17.0, macOS 14.0, *), !UserDefaults.mapUseLegacy { + MeshMap(router: appState.router) + .tabItem { + Label("map", systemImage: "map") + } + .tag(NavigationState.Tab.map) } else { - NodeMap() + NodeMap(router: appState.router) .tabItem { Label("map", systemImage: "map") } - .tag(Tab.map) + .tag(NavigationState.Tab.map) } - Settings() - .tabItem { - Label("settings", systemImage: "gear") - .font(.title) - } - .tag(Tab.settings) + + Settings( + router: appState.router + ) + .tabItem { + Label("settings", systemImage: "gear") + .font(.title) + } + .tag(NavigationState.Tab.settings) } } } -// #Preview { -// if #available(iOS 17.0, *) { -// // ContentView(deepLinkManager: .init()) -// } else { -// // Fallback on earlier versions -// } -// } - -// struct ContentView_Previews: PreviewProvider { -// static var previews: some View { -// ContentView() -// } -// } - -enum Tab: Hashable { - case contacts - case messages - case map - case ble - case nodes - case settings -} diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index c4957bbc5..4292c104b 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -11,7 +11,6 @@ import OSLog struct ChannelList: View { - @StateObject var appState = AppState.shared @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @@ -142,7 +141,6 @@ struct ChannelList: View { Button(role: .destructive) { deleteChannelMessages(channel: channelSelection!, context: context) context.refresh(myInfo, mergeChanges: true) - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages channelSelection = nil } label: { Text("delete") diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 882dad079..03f9d5136 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -11,7 +11,7 @@ import OSLog import SwiftUI struct ChannelMessageList: View { - @StateObject var appState = AppState.shared + @EnvironmentObject var appState: AppState @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @@ -80,7 +80,6 @@ struct ChannelMessageList: View { TapbackResponses(message: message) { appState.unreadChannelMessages = myInfo.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages context.refresh(myInfo, mergeChanges: true) } @@ -118,7 +117,6 @@ struct ChannelMessageList: View { try context.save() Logger.data.info("📖 [App] Read message \(message.messageId) ") appState.unreadChannelMessages = myInfo.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages context.refresh(myInfo, mergeChanges: true) } catch { Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 35206d7c9..b06fe054e 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -14,52 +14,72 @@ import TipKit struct Messages: View { - @StateObject var appState = AppState.shared @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager + @ObservedObject + var router: Router + + @Binding + var unreadChannelMessages: Int + + @Binding + var unreadDirectMessages: Int + + // Aliases the navigation state for the NavigationSplitView sidebar selection + private var messagesSelection: Binding { + Binding( + get: { + guard case .messages(let state) = router.navigationState else { + return nil + } + return state + }, + set: { newValue in + router.navigationState = .messages(newValue) + } + ) + } + @State var node: NodeInfoEntity? @State private var userSelection: UserEntity? // Nothing selected by default. @State private var channelSelection: ChannelEntity? // Nothing selected by default. @State private var columnVisibility = NavigationSplitViewVisibility.all - enum MessagesSidebar { - case groupMessages - case directMessages - } - var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - // Sidebar - List { - NavigationLink { - ChannelList(node: node) - } label: { - Image(systemName: "person.3") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.accentColor) - .brightness(0.2) - .font(.title) - Text("channels") - .font(.title2) - .badge(appState.unreadChannelMessages) - .padding(.vertical) + List(selection: messagesSelection) { + NavigationLink(value: MessagesNavigationState.channels()) { + Label { + Text("channels") + .badge(unreadChannelMessages) + .font(.title2) + .padding() + } icon: { + Image(systemName: "person.3") + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .font(.title2) + .padding() + } + } - NavigationLink { - UserList(node: node) - } label: { - Image(systemName: "person.circle") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.accentColor) - .brightness(0.2) - .font(.largeTitle) - Text("direct.messages") - .font(.title2) - .badge(appState.unreadDirectMessages) - .padding(.vertical) + NavigationLink(value: MessagesNavigationState.directMessages()) { + Label { + Text("direct.messages") + .badge(unreadDirectMessages) + .font(.title2) + .padding() + } icon: { + Image(systemName: "person.circle") + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .font(.title2) + .padding() + } } + if #available(iOS 17.0, macOS 14.0, *) { TipView(MessagesTip(), arrowEdge: .top) } @@ -67,48 +87,33 @@ struct Messages: View { .navigationTitle("messages") .navigationBarTitleDisplayMode(.large) .navigationBarItems(leading: MeshtasticLogo()) - .onChange(of: (appState.navigationPath)) { newPath in - - if (newPath?.hasPrefix("meshtastic://messages")) != nil { - - if let urlComponent = URLComponents(string: newPath ?? "") { - let queryItems = urlComponent.queryItems - let messageId = queryItems?.first(where: { $0.name == "messageId" })?.value - let channel = queryItems?.first(where: { $0.name == "channel" })?.value - - if let channel { - Logger.services.info("Deep Link Channel \(channel)") - // selectedNode = nodes.first(where: { $0.num == Int64(nodeNum ?? "-1") }) - // AppState.shared.navigationPath = nil - } else { - Logger.services.info("Channel Deep Link not found") - } - } - } - } .onAppear { if self.bleManager.context == nil { self.bleManager.context = context } - if UserDefaults.preferredPeripheralId.count > 0 { - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(UserDefaults.preferredPeripheralNum)) - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - // Found a node, check it for a region - if !fetchedNode.isEmpty { - node = fetchedNode[0] - } - } catch { - } + let nodeId = Int64(UserDefaults.preferredPeripheralNum) + if nodeId > 0 { + node = getNodeInfo(id: nodeId, context: context) } } - } content: { - + if case .messages(let state) = router.navigationState { + switch state { + case .channels: + // TODO: support linking to the channel + ChannelList(node: node) + case .directMessages(userNum: let userNum, messageId: _): + UserList( + node: node, + selectedUserNum: userNum + ) + default: + EmptyView() + } + } } detail: { - + } } } diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index b0c9beb09..b2c59525b 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -14,7 +14,6 @@ import TipKit struct UserList: View { - @StateObject var appState = AppState.shared @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @State private var searchText = "" @@ -160,7 +159,6 @@ struct UserList: View { Button(role: .destructive) { deleteUserMessages(user: userSelection!, context: context) context.refresh(node!.user!, mergeChanges: true) - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } label: { Text("delete") } diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 41c83f51a..5a9b7b0e8 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -10,10 +10,11 @@ import CoreData import OSLog struct UserMessageList: View { - @StateObject var appState = AppState.shared - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var bleManager: BLEManager + @Environment(\.managedObjectContext) var context + // Keyboard State @FocusState var messageFieldFocused: Bool // View State Items @@ -64,7 +65,6 @@ struct UserMessageList: View { TapbackResponses(message: message) { appState.unreadDirectMessages = user.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } HStack { @@ -102,7 +102,6 @@ struct UserMessageList: View { try context.save() Logger.data.info("📖 [App] Read message \(message.messageId) ") appState.unreadDirectMessages = user.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } catch { Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 2c943be99..b8fb4f4f1 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -11,7 +11,6 @@ import MapKit @available(iOS 17.0, macOS 14.0, *) struct MeshMapContent: MapContent { - @StateObject var appState = AppState.shared /// Parameters @Binding var showUserLocation: Bool @AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index caa3f5d62..71cbfaf28 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -19,7 +19,10 @@ struct MeshMap: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @StateObject var appState = AppState.shared + + @ObservedObject + var router: Router + /// Parameters @State var showUserLocation: Bool = true /// Map State User Defaults @@ -106,33 +109,10 @@ struct MeshMap: View { .sheet(isPresented: $isEditingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap) } -// .onChange(of: (appState.navigationPath)) { newPath in -// -// if ((newPath?.hasPrefix("meshtastic://open-waypoint")) != nil) { -// guard let url = URL(string: appState.navigationPath ?? "NONE") else { -// logger.error("Invalid URL") -// return -// } -// guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { -// logger.error("Invalid URL Components") -// return -// } -// guard let action = components.host, action == "open-waypoint" else { -// logger.error("Unknown waypoint URL action") -// return -// } -// guard let waypointId = components.queryItems?.first(where: { $0.name == "id" })?.value else { -// logger.error("Waypoint id not found") -// return -// } -// guard let waypoint = waypoints.first(where: { $0.id == Int64(waypointId) }) else { -// logger.error("Waypoint not found") -// return -// } -// //showWaypoints = true -// //position = .camera(MapCamera(centerCoordinate: waypoint.coordinate, distance: 1000, heading: 0, pitch: 60)) -// } -// } + .onChange(of: router.navigationState) { + guard case .map(let selectedNodeNum) = router.navigationState else { return } + //TODO: handle deep link for waypoints + } .onChange(of: (selectedMapLayer)) { newMapLayer in switch selectedMapLayer { case .standard: diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 096261ad7..54db93aaa 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -9,8 +9,15 @@ import CoreLocation import OSLog struct NodeList: View { + @Environment(\.managedObjectContext) + var context + + @EnvironmentObject + var bleManager: BLEManager + + @ObservedObject + var router: Router - @StateObject var appState = AppState.shared @State private var columnVisibility = NavigationSplitViewVisibility.all @State private var selectedNode: NodeInfoEntity? @State private var searchText = "" @@ -28,14 +35,11 @@ struct NodeList: View { @SceneStorage("selectedDetailView") var selectedDetailView: String? - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager - @FetchRequest( sortDescriptors: [ NSSortDescriptor(key: "favorite", ascending: false), NSSortDescriptor(key: "lastHeard", ascending: false), - NSSortDescriptor(key: "user.longName", ascending: true), + NSSortDescriptor(key: "user.longName", ascending: true) ], animation: .default ) @@ -179,7 +183,6 @@ struct NodeList: View { } else { Text("Select something to view") } - } .navigationSplitViewStyle(.balanced) .onChange(of: searchText) { _ in @@ -233,32 +236,18 @@ struct NodeList: View { await searchNodeList() } } - .onChange(of: (appState.navigationPath)) { newPath in - - guard let deepLink = newPath else { - return - } - if deepLink.hasPrefix("meshtastic://nodes") { - - if let urlComponent = URLComponents(string: deepLink) { - let queryItems = urlComponent.queryItems - let nodeNum = queryItems?.first(where: { $0.name == "nodenum" })?.value - if nodeNum == nil { - Logger.data.debug("nodeNum not found") - } else { - selectedNode = nodes.first(where: { $0.num == Int64(nodeNum ?? "-1") }) - AppState.shared.navigationPath = nil - } - } - } - } .onAppear { if self.bleManager.context == nil { self.bleManager.context = context } + Task { await searchNodeList() } + // Handle deep link routing + if case .nodes(let selected) = router.navigationState, let selectedNodeNum = selected { + self.selectedNode = getNodeInfo(id: selectedNodeNum, context: context) + } } } diff --git a/Meshtastic/Views/Nodes/NodeMap.swift b/Meshtastic/Views/Nodes/NodeMap.swift index cd79ef0dc..d65894dd1 100644 --- a/Meshtastic/Views/Nodes/NodeMap.swift +++ b/Meshtastic/Views/Nodes/NodeMap.swift @@ -13,7 +13,10 @@ import CoreData struct NodeMap: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @StateObject var appState = AppState.shared + + @ObservedObject + var router: Router + @State var selectedMapLayer: MapLayer = UserDefaults.mapLayer @State var enableMapRecentering: Bool = UserDefaults.enableMapRecentering @State var enableMapRouteLines: Bool = UserDefaults.enableMapRouteLines diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 487a35850..d15302749 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -14,44 +14,22 @@ import TipKit struct Settings: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false), - NSSortDescriptor(key: "user.longName", ascending: true)], animation: .default) + @FetchRequest( + sortDescriptors: [ + NSSortDescriptor(key: "favorite", ascending: false), + NSSortDescriptor(key: "user.longName", ascending: true) + ], + animation: .default + ) private var nodes: FetchedResults + @State private var selectedNode: Int = 0 @State private var preferredNodeNum: Int = 0 - @State private var selection: SettingsSidebar = .about - - enum SettingsSidebar { - case appSettings - case routes - case routeRecorder - case shareChannels - case userConfig - case loraConfig - case channelConfig - case bluetoothConfig - case deviceConfig - case displayConfig - case networkConfig - case paxCounterConfig - case positionConfig - case powerConfig - case ambientLightingConfig - case cannedMessagesConfig - case detectionSensorConfig - case externalNotificationConfig - case mqttConfig - case rangeTestConfig - case ringtoneConfig - case serialConfig - case storeAndForwardConfig - case telemetryConfig - case meshLog - case adminMessageLog - case about - case appLog - case appData - } + + @ObservedObject + var router: Router + + // MARK: Views var radioConfigurationSection: some View { Section("radio.configuration") { @@ -77,9 +55,7 @@ struct Settings: View { .foregroundColor(.gray) } - NavigationLink { - LoRaConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { + NavigationLink(value: SettingsNavigationState.lora) { Label { Text("lora") } icon: { @@ -87,72 +63,272 @@ struct Settings: View { .rotationEffect(.degrees(-90)) } } - .tag(SettingsSidebar.loraConfig) - NavigationLink { - Channels(node: node) - } label: { + NavigationLink(value: SettingsNavigationState.channels) { Label { Text("channels") } icon: { Image(systemName: "fibrechannel") } } - .tag(SettingsSidebar.channelConfig) .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) - NavigationLink { - ShareChannels(node: node) - } label: { + NavigationLink(value: SettingsNavigationState.shareQRCode) { Label { Text("share.channels") } icon: { Image(systemName: "qrcode") } } - .tag(SettingsSidebar.shareChannels) + .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) + } + } + + var deviceConfigurationSection: some View { + Section("device.configuration") { + NavigationLink(value: SettingsNavigationState.user) { + Label { + Text("user") + } icon: { + Image(systemName: "person.crop.rectangle.fill") + } + } + + NavigationLink(value: SettingsNavigationState.bluetooth) { + Label { + Text("bluetooth") + } icon: { + Image(systemName: "antenna.radiowaves.left.and.right") + } + } + + NavigationLink(value: SettingsNavigationState.device) { + Label { + Text("device") + } icon: { + Image(systemName: "flipphone") + } + } + + NavigationLink(value: SettingsNavigationState.display) { + Label { + Text("display") + } icon: { + Image(systemName: "display") + } + } + + NavigationLink(value: SettingsNavigationState.network) { + Label { + Text("network") + } icon: { + Image(systemName: "network") + } + } + + NavigationLink(value: SettingsNavigationState.position) { + Label { + Text("position") + } icon: { + Image(systemName: "location") + } + } + + NavigationLink(value: SettingsNavigationState.power) { + Label { + Text("config.power.settings") + } icon: { + Image(systemName: "bolt.fill") + } + } + } + } + + var moduleConfigurationSection: some View { + Section("module.configuration") { + if #available(iOS 17.0, macOS 14.0, *) { + NavigationLink(value: SettingsNavigationState.ambientLighting) { + Label { + Text("ambient.lighting") + } icon: { + Image(systemName: "light.max") + } + } + } + + NavigationLink(value: SettingsNavigationState.cannedMessages) { + Label { + Text("canned.messages") + } icon: { + Image(systemName: "list.bullet.rectangle.fill") + } + } + + NavigationLink(value: SettingsNavigationState.detectionSensor) { + Label { + Text("detection.sensor") + } icon: { + Image(systemName: "sensor") + } + } + + NavigationLink(value: SettingsNavigationState.externalNotification) { + Label { + Text("external.notification") + } icon: { + Image(systemName: "megaphone") + } + } + + NavigationLink(value: SettingsNavigationState.mqtt) { + Label { + Text("mqtt") + } icon: { + Image(systemName: "dot.radiowaves.up.forward") + } + } + + NavigationLink(value: SettingsNavigationState.rangeTest) { + Label { + Text("range.test") + } icon: { + Image(systemName: "point.3.connected.trianglepath.dotted") + } + } + + if let node = nodes.first(where: { $0.num == preferredNodeNum }), + node.metadata?.hasWifi ?? false { + NavigationLink(value: SettingsNavigationState.paxCounter) { + Label { + Text("config.module.paxcounter.settings") + } icon: { + Image(systemName: "figure.walk.motion") + } + } + } + + NavigationLink(value: SettingsNavigationState.ringtone) { + Label { + Text("ringtone") + } icon: { + Image(systemName: "music.note.list") + } + } + + NavigationLink(value: SettingsNavigationState.serial) { + Label { + Text("serial") + } icon: { + Image(systemName: "terminal") + } + } + + NavigationLink(value: SettingsNavigationState.storeAndForward) { + Label { + Text("storeforward") + } icon: { + Image(systemName: "envelope.arrow.triangle.branch") + } + } + + NavigationLink(value: SettingsNavigationState.telemetry) { + Label { + Text("telemetry") + } icon: { + Image(systemName: "chart.xyaxis.line") + } + } + } + } + + var loggingSection: some View { + Section(header: Text("logging")) { + NavigationLink(value: SettingsNavigationState.meshLog) { + Label { + Text("mesh.log") + } icon: { + Image(systemName: "list.bullet.rectangle") + } + } + + if #available (iOS 17.4, *) { + NavigationLink(value: SettingsNavigationState.debugLogs) { + Label { + Text("Debug Logs") + } icon: { + Image(systemName: "stethoscope") + } + } + } + } + } + + var developersSection: some View { + Section(header: Text("Developers")) { + NavigationLink(value: SettingsNavigationState.appFiles) { + Label { + Text("App Files") + } icon: { + Image(systemName: "folder") + } + } + } + } + + var firmwareSection: some View { + Section(header: Text("Firmware")) { + NavigationLink(value: SettingsNavigationState.firmwareUpdates) { + Label { + Text("Firmware Updates") + } icon: { + Image(systemName: "arrow.up.arrow.down.square") + } + } .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) } } var body: some View { - NavigationSplitView { + NavigationStack( + path: Binding<[SettingsNavigationState]>( + get: { + guard case .settings(let route) = router.navigationState, let setting = route else { + return [] + } + return [setting] + }, + set: { newPath in + router.navigationState = .settings(newPath.first) + } + ) + ) { let node = nodes.first(where: { $0.num == preferredNodeNum }) List { - NavigationLink { - AboutMeshtastic() - } label: { + NavigationLink(value: SettingsNavigationState.about) { Label { Text("about.meshtastic") } icon: { Image(systemName: "questionmark.app") } } - .tag(SettingsSidebar.about) - NavigationLink { - AppSettings() - } label: { + + NavigationLink(value: SettingsNavigationState.appSettings) { Label { Text("appsettings") } icon: { Image(systemName: "gearshape") } } - .tag(SettingsSidebar.appSettings) if #available(iOS 17.0, macOS 14.0, *) { - NavigationLink { - Routes() - } label: { + NavigationLink(value: SettingsNavigationState.routes) { Label { Text("routes") } icon: { Image(systemName: "road.lanes.curved.right") } } - .tag(SettingsSidebar.routes) - NavigationLink { - RouteRecorder() - } label: { + + NavigationLink(value: SettingsNavigationState.routeRecorder) { Label { Text("route.recorder") } icon: { @@ -160,10 +336,9 @@ struct Settings: View { .foregroundColor(.red) } } - .tag(SettingsSidebar.routeRecorder) } - let hasAdmin = node?.myInfo?.adminIndex ?? 0 > 0 ? true : false + let hasAdmin = node?.myInfo?.adminIndex ?? 0 > 0 if !(node?.deviceConfig?.isManaged ?? false) { if bleManager.connectedPeripheral != nil { @@ -225,246 +400,84 @@ struct Settings: View { } } radioConfigurationSection - Section("device.configuration") { - NavigationLink { - UserConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("user") - } icon: { - Image(systemName: "person.crop.rectangle.fill") - } - } - .tag(SettingsSidebar.userConfig) - NavigationLink { - BluetoothConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("bluetooth") - } icon: { - Image(systemName: "antenna.radiowaves.left.and.right") - } - } - .tag(SettingsSidebar.bluetoothConfig) - NavigationLink { - DeviceConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("device") - } icon: { - Image(systemName: "flipphone") - } - } - .tag(SettingsSidebar.deviceConfig) - NavigationLink { - DisplayConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("display") - } icon: { - Image(systemName: "display") - } - } - .tag(SettingsSidebar.displayConfig) - NavigationLink { - NetworkConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("network") - } icon: { - Image(systemName: "network") - } - } - .tag(SettingsSidebar.networkConfig) - NavigationLink { - PositionConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("position") - } icon: { - Image(systemName: "location") - } - } - .tag(SettingsSidebar.positionConfig) - - NavigationLink { - PowerConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("config.power.settings") - } icon: { - Image(systemName: "bolt.fill") - } - } - .tag(SettingsSidebar.powerConfig) - } - Section("module.configuration") { - if #available(iOS 17.0, macOS 14.0, *) { - NavigationLink { - AmbientLightingConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("ambient.lighting") - } icon: { - Image(systemName: "light.max") - } - } - .tag(SettingsSidebar.ambientLightingConfig) - } - NavigationLink { - CannedMessagesConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("canned.messages") - } icon: { - Image(systemName: "list.bullet.rectangle.fill") - } - } - .tag(SettingsSidebar.cannedMessagesConfig) - NavigationLink { - DetectionSensorConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("detection.sensor") - } icon: { - Image(systemName: "sensor") - } - } - .tag(SettingsSidebar.detectionSensorConfig) - NavigationLink { - ExternalNotificationConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("external.notification") - } icon: { - Image(systemName: "megaphone") - } - } - .tag(SettingsSidebar.externalNotificationConfig) - NavigationLink { - MQTTConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("mqtt") - } icon: { - Image(systemName: "dot.radiowaves.up.forward") - } - } - .tag(SettingsSidebar.mqttConfig) - NavigationLink { - RangeTestConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("range.test") - } icon: { - Image(systemName: "point.3.connected.trianglepath.dotted") - } - } - .tag(SettingsSidebar.rangeTestConfig) - if node?.metadata?.hasWifi ?? false { - NavigationLink { - PaxCounterConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("config.module.paxcounter.settings") - } icon: { - Image(systemName: "figure.walk.motion") - } - } - .tag(SettingsSidebar.paxCounterConfig) - } - NavigationLink { - RtttlConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("ringtone") - } icon: { - Image(systemName: "music.note.list") - } - } - .tag(SettingsSidebar.ringtoneConfig) - NavigationLink { - SerialConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("serial") - } icon: { - Image(systemName: "terminal") - } - } - .tag(SettingsSidebar.serialConfig) - NavigationLink { - StoreForwardConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("storeforward") - } icon: { - Image(systemName: "envelope.arrow.triangle.branch") - } - } - .tag(SettingsSidebar.storeAndForwardConfig) - NavigationLink { - TelemetryConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("telemetry") - } icon: { - Image(systemName: "chart.xyaxis.line") - } - } - .tag(SettingsSidebar.telemetryConfig) + deviceConfigurationSection + moduleConfigurationSection + loggingSection +#if DEBUG + developersSection +#endif + firmwareSection + } + } + .navigationDestination(for: SettingsNavigationState.self) { destination in + let node = nodes.first(where: { $0.num == preferredNodeNum }) + switch destination { + case .about: + AboutMeshtastic() + case .appSettings: + AppSettings() + case .routes: + if #available(iOS 17.0, *) { + Routes() } - Section(header: Text("logging")) { - NavigationLink { - MeshLog() - } label: { - Label { - Text("mesh.log") - } icon: { - Image(systemName: "list.bullet.rectangle") - } - } - .tag(SettingsSidebar.meshLog) - if #available (iOS 17.4, *) { - NavigationLink { - AppLog() - } label: { - Label { - Text("Debug Logs") - } icon: { - Image(systemName: "stethoscope") - } - } - .tag(SettingsSidebar.appLog) - } + case .routeRecorder: + if #available(iOS 17.0, *) { + RouteRecorder() } -#if DEBUG - Section(header: Text("Developers")) { - NavigationLink { - AppData() - } label: { - Label { - Text("App Files") - } icon: { - Image(systemName: "folder") - } - } - .tag(SettingsSidebar.appData) + case .lora: + LoRaConfig(node: nodes.first(where: { $0.num == selectedNode })) + case .channels: + Channels(node: node) + case .shareQRCode: + ShareChannels(node: node) + case .user: + UserConfig(node: node) + case .bluetooth: + BluetoothConfig(node: node) + case .device: + DeviceConfig(node: node) + case .display: + DisplayConfig(node: node) + case .network: + NetworkConfig(node: node) + case .position: + PositionConfig(node: node) + case .power: + PowerConfig(node: node) + case .ambientLighting: + if #available(iOS 17.0, *) { + AmbientLightingConfig(node: node) } -#endif - Section(header: Text("Firmware")) { - NavigationLink { - Firmware(node: nodes.first(where: { $0.num == preferredNodeNum })) - } label: { - Label { - Text("Firmware Updates") - } icon: { - Image(systemName: "arrow.up.arrow.down.square") - } - } - .tag(SettingsSidebar.about) - .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) + case .cannedMessages: + CannedMessagesConfig(node: node) + case .detectionSensor: + DetectionSensorConfig(node: node) + case .externalNotification: + ExternalNotificationConfig(node: node) + case .mqtt: + MQTTConfig(node: node) + case .rangeTest: + RangeTestConfig(node: node) + case .paxCounter: + PaxCounterConfig(node: node) + case .ringtone: + RtttlConfig(node: node) + case .serial: + SerialConfig(node: node) + case .storeAndForward: + StoreForwardConfig(node: node) + case .telemetry: + TelemetryConfig(node: node) + case .meshLog: + MeshLog() + case .debugLogs: + if #available(iOS 17.4, *) { + AppLog() } + case .appFiles: + AppData() + case .firmwareUpdates: + Firmware(node: node) } } .onChange(of: UserDefaults.preferredPeripheralNum ) { newConnectedNode in @@ -489,18 +502,11 @@ struct Settings: View { } } } - .listStyle(GroupedListStyle()) + .listStyle(.insetGrouped) .navigationTitle("settings") - .navigationBarItems(leading: - MeshtasticLogo() + .navigationBarItems( + leading: MeshtasticLogo() ) } - detail: { - if #available (iOS 17, *) { - ContentUnavailableView("select.menu.item", systemImage: "gear") - } else { - Text("select.menu.item") - } - } } } diff --git a/MeshtasticTests/RouterTests.swift b/MeshtasticTests/RouterTests.swift new file mode 100644 index 000000000..81b07276d --- /dev/null +++ b/MeshtasticTests/RouterTests.swift @@ -0,0 +1,57 @@ +import Foundation +import XCTest + +@testable import Meshtastic + +final class RouterTests: XCTestCase { + + func testInitialState() throws { + XCTAssertEqual(Router().navigationState, .bluetooth) + } + + func testRouteTo() throws { + let router = Router(navigationState: .bluetooth) + router.route(to: .settings(.about)) + XCTAssertEqual(router.navigationState, .settings(.about)) + } + + func testRouteURL() throws { + // Messages + try assertRoute("meshtastic:///messages", .messages()) + try assertRoute( + "meshtastic:///messages?channelId=0&messageId=1122334455", + .messages(.channels(channelId: 0, messageId: 1122334455)) + ) + try assertRoute( + "meshtastic:///messages?userNum=123456789&messageId=9876543210", + .messages(.directMessages(userNum: 123456789, messageId: 9876543210)) + ) + + // Bluetooth + try assertRoute("meshtastic:///bluetooth", .bluetooth) + + // Nodes + try assertRoute("meshtastic:///nodes", .nodes()) + try assertRoute("meshtastic:///nodes?nodenum=1234567890", .nodes(selectedNodeNum: 1234567890)) + + // Map + try assertRoute("meshtastic:///map", .map()) + try assertRoute("meshtastic:///map?waypointId=123456", .map(.waypoint(123456))) + try assertRoute("meshtastic:///map?nodenum=1234567890", .map(.selectedNode(1234567890))) + + // Settings + try assertRoute("meshtastic:///settings", .settings()) + try assertRoute("meshtastic:///settings/about", .settings(.about)) + try assertRoute("meshtastic:///settings/invalidSetting", .settings()) + } + + private func assertRoute( + router: Router = Router(), + _ urlString: String, + _ destination: NavigationState + ) throws { + let url = try XCTUnwrap(URL(string: urlString)) + router.route(url: url) + XCTAssertEqual(router.navigationState, destination) + } +} diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 80d1fb50b..396aaac9b 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget { ActivityConfiguration(for: MeshActivityAttributes.self) { context in LiveActivityView(nodeName: context.attributes.name, channelUtilization: context.state.channelUtilization, airtime: context.state.airtime, batteryLevel: context.state.batteryLevel, nodes: 17, nodesOnline: 7, timerRange: context.state.timerRange) - .widgetURL(URL(string: "meshtastic://node/\(context.attributes.name)")) + .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) } dynamicIsland: { context in DynamicIsland { @@ -95,7 +95,7 @@ struct WidgetsLiveActivity: Widget { .contentMargins(.trailing, 32, for: .expanded) .contentMargins([.leading, .top, .bottom], 6, for: .compactLeading) .contentMargins(.all, 6, for: .minimal) - .widgetURL(URL(string: "meshtastic://node/\(context.attributes.name)")) + .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) } } }