Refactor the apps routing structure to enable app-wide navigation through a Router to improve how deep link URLs are handled

This commit is contained in:
Blake McAnally 2024-07-10 21:17:14 -05:00
parent e0640143df
commit 3a746af27e
25 changed files with 1056 additions and 575 deletions

View file

@ -18853,6 +18853,7 @@
}
},
"select.menu.item" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {

View file

@ -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 */; };
@ -205,6 +209,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 */;
@ -236,6 +247,11 @@
2519268F2C3CB44900249DF5 /* ClientHistoryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientHistoryButton.swift; sourceTree = "<group>"; };
251926912C3CB52300249DF5 /* DeleteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNodeButton.swift; sourceTree = "<group>"; };
25AECD4E2C2F723200862C8E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = "<group>"; };
25F5D5BF2C3F6DA6008036E3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
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 = "<group>"; };
6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = "<group>"; };
6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = "<group>"; };
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = "<group>"; };
@ -464,6 +480,13 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
25F5D5C42C4375A8008036E3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DDC2E15126CE248E0042C5E4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -500,6 +523,23 @@
path = Actions;
sourceTree = "<group>";
};
25F5D5BC2C3F6D7B008036E3 /* Router */ = {
isa = PBXGroup;
children = (
25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */,
25F5D5BF2C3F6DA6008036E3 /* Router.swift */,
);
path = Router;
sourceTree = "<group>";
};
25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = {
isa = PBXGroup;
children = (
25F5D5D02C4375DF008036E3 /* RouterTests.swift */,
);
path = MeshtasticTests;
sourceTree = "<group>";
};
C9483F6B2773016700998F6B /* MapKitMap */ = {
isa = PBXGroup;
children = (
@ -739,6 +779,7 @@
DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */,
DDC2E15626CE248E0042C5E4 /* Meshtastic */,
DDDE59F729AF163D00490C6C /* Widgets */,
25F5D5C82C4375A8008036E3 /* MeshtasticTests */,
DDC2E15526CE248E0042C5E4 /* Products */,
DD8EDE9226F97A2B00A5A10B /* Frameworks */,
);
@ -750,6 +791,7 @@
children = (
DDC2E15426CE248E0042C5E4 /* Meshtastic.app */,
DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */,
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -757,6 +799,7 @@
DDC2E15626CE248E0042C5E4 /* Meshtastic */ = {
isa = PBXGroup;
children = (
25F5D5BC2C3F6D7B008036E3 /* Router */,
DD7709392AA1ABA1007A8BF0 /* Tips */,
DD90860A26F645B700DC5189 /* Meshtastic.entitlements */,
DD8ED9C6289CE4A100B3B0AB /* Enums */,
@ -768,6 +811,7 @@
DDC2E18926CE24F70042C5E4 /* Resources */,
DDC2E18726CE24E40042C5E4 /* Views */,
DDC2E15726CE248E0042C5E4 /* MeshtasticApp.swift */,
25F5D5C12C3F6E4B008036E3 /* AppState.swift */,
DDC2E16526CE248F0042C5E4 /* Info.plist */,
DDC2E15D26CE248F0042C5E4 /* Preview Content */,
6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */,
@ -962,6 +1006,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" */;
@ -1014,9 +1076,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;
@ -1055,11 +1121,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;
@ -1106,6 +1180,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;
@ -1160,6 +1242,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 */,
@ -1186,6 +1269,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 */,
@ -1264,6 +1348,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 */,
@ -1303,6 +1388,11 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
25F5D5CC2C4375A8008036E3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = DDC2E15326CE248E0042C5E4 /* Meshtastic */;
targetProxy = 25F5D5CB2C4375A8008036E3 /* PBXContainerItemProxy */;
};
DDDE5A0229AF163E00490C6C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
@ -1312,6 +1402,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 = {
@ -1574,6 +1710,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 = (

View file

@ -33,7 +33,7 @@
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DDC2E16926CE248F0042C5E4"
BlueprintIdentifier = "25F5D5C62C4375A8008036E3"
BuildableName = "MeshtasticTests.xctest"
BlueprintName = "MeshtasticTests"
ReferencedContainer = "container:Meshtastic.xcodeproj">

33
Meshtastic/AppState.swift Normal file
View file

@ -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<AnyCancellable> = []
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)
}
}

View file

@ -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,6 @@ 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
UserDefaults.firmwareVersion = connectedVersion
}
let supportedVersion = connectedVersion == "0.0.0" || self.minimumVersion.compare(connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(connectedVersion, options: .numeric) == .orderedSame
@ -739,7 +743,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 +764,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 +785,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 +915,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 +1135,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 +1181,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 +1236,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
return false
}
@MainActor
public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) -> Bool {
let fromNodeNum = connectedPeripheral.num
guard let positionPacket = getPositionFromPhoneGPS(destNum: destNum) else {
@ -1256,6 +1277,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
return false
}
}
@MainActor
@objc func positionTimerFired(timer: Timer) {
// Check for connected node
if connectedPeripheral != nil {
@ -3060,10 +3083,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
)
}
}
}

View file

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

View file

@ -710,7 +710,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()
@ -744,7 +744,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)
@ -828,12 +835,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
@ -845,7 +850,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()
@ -860,7 +865,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 {
@ -876,7 +880,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)")
@ -941,10 +945,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()

View file

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

View file

@ -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) {
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
// This method is called when a user clicks on the notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
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()
}
}

View file

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

View file

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

View file

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

View file

@ -6,75 +6,69 @@ import SwiftUI
@available(iOS 17.0, *)
struct ContentView: View {
@ObservedObject
var appState: AppState
private var tabBinding: Binding<NavigationState.Tab> {
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)
}
} else {
NodeMap()
.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(Tab.map)
.tag(NavigationState.Tab.map)
} else {
NodeMap(router: appState.router)
.tabItem {
Label("map", systemImage: "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
}

View file

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

View file

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

View file

@ -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<MessagesNavigationState?> {
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: {
}
}
}

View file

@ -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 = ""
@ -161,7 +160,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")
}

View file

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

View file

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

View file

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

View file

@ -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 = ""
@ -37,14 +44,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: .spring
)
@ -198,7 +202,6 @@ struct NodeList: View {
} else {
Text("Select something to view")
}
}
.navigationSplitViewStyle(.balanced)
.onChange(of: searchText) { _ in
@ -242,32 +245,23 @@ 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
}
}
.onChange(of: distanceFilter) { _ in
Task {
await searchNodeList()
}
}
.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)
}
}
}

View file

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

View file

@ -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<NodeInfoEntity>
@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)
}
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)
}
}
deviceConfigurationSection
moduleConfigurationSection
loggingSection
#if DEBUG
Section(header: Text("Developers")) {
NavigationLink {
AppData()
} label: {
Label {
Text("App Files")
} icon: {
Image(systemName: "folder")
}
}
.tag(SettingsSidebar.appData)
}
developersSection
#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)
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()
}
case .routeRecorder:
if #available(iOS 17.0, *) {
RouteRecorder()
}
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)
}
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")
}
}
}
}

View file

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

View file

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