diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 58e2baa5..62cf1085 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; }; BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; }; + BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; }; BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */; }; BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */; }; BCE2D3C72C7B0D0A008E6199 /* ShortcutsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */; }; @@ -176,8 +177,6 @@ DDB6CCFB2AAF805100945AF6 /* NodeMapSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */; }; DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A0E2A05920E006ED576 /* FileManager.swift */; }; DDB75A112A059258006ED576 /* Url.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A102A059258006ED576 /* Url.swift */; }; - DDB75A142A0593E2006ED576 /* OfflineTileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A132A0593E2006ED576 /* OfflineTileManager.swift */; }; - DDB75A162A0594AD006ED576 /* TileOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A152A0594AD006ED576 /* TileOverlay.swift */; }; DDB75A1A2A05EB67006ED576 /* alpha.png in Resources */ = {isa = PBXBuildFile; fileRef = DDB75A192A05EB67006ED576 /* alpha.png */; }; DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */; }; DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A202A12B954006ED576 /* LoRaSignalStrength.swift */; }; @@ -326,6 +325,7 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; + BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutDownNodeIntent.swift; sourceTree = ""; }; BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartNodeIntent.swift; sourceTree = ""; }; BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsProvider.swift; sourceTree = ""; }; @@ -475,8 +475,6 @@ DDB759E12A04B264006ED576 /* MeshtasticDataModelV12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV12.xcdatamodel; sourceTree = ""; }; DDB75A0E2A05920E006ED576 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; DDB75A102A059258006ED576 /* Url.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Url.swift; sourceTree = ""; }; - DDB75A132A0593E2006ED576 /* OfflineTileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineTileManager.swift; sourceTree = ""; }; - DDB75A152A0594AD006ED576 /* TileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileOverlay.swift; sourceTree = ""; }; DDB75A192A05EB67006ED576 /* alpha.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = alpha.png; sourceTree = ""; }; DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoRaSignalStrengthIndicator.swift; sourceTree = ""; }; DDB75A1F2A10766D006ED576 /* MeshtasticDataModelV13.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV13.xcdatamodel; sourceTree = ""; }; @@ -918,15 +916,6 @@ path = Map; sourceTree = ""; }; - DDB75A122A0593CD006ED576 /* Map */ = { - isa = PBXGroup; - children = ( - DDB75A132A0593E2006ED576 /* OfflineTileManager.swift */, - DDB75A152A0594AD006ED576 /* TileOverlay.swift */, - ); - path = Map; - sourceTree = ""; - }; DDC2E14B26CE248E0042C5E4 = { isa = PBXGroup; children = ( @@ -1064,7 +1053,6 @@ isa = PBXGroup; children = ( DDD43FE12A78C86B0083A3E9 /* Mqtt */, - DDB75A122A0593CD006ED576 /* Map */, DDAF8C5226EB1DF10058C060 /* BLEManager.swift */, DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */, DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */, @@ -1121,6 +1109,7 @@ DDDB26412AABF655003AFCB7 /* NodeListItem.swift */, DDDCD56F2BB26F5C00BE6B60 /* NodeListFilter.swift */, 251926882C3BAF2E00249DF5 /* Actions */, + BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */, ); path = Helpers; sourceTree = ""; @@ -1278,10 +1267,9 @@ pl, he, fr, - "zh-Hant-TW", se, - "pt-PT", sr, + it, ); mainGroup = DDC2E14B26CE248E0042C5E4; packageReferences = ( @@ -1446,7 +1434,6 @@ DDDB444229F8A88700EE2349 /* Double.swift in Sources */, DDF45C342BC1A48E005ED5F2 /* MQTTIcon.swift in Sources */, DDA9515A2BC6624100CEA535 /* TelemetryWeather.swift in Sources */, - DDB75A162A0594AD006ED576 /* TileOverlay.swift in Sources */, DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */, DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */, DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */, @@ -1521,7 +1508,6 @@ DD6F65762C6EA5490053C113 /* AckErrors.swift in Sources */, DDDB445029F8AC9C00EE2349 /* UIImage.swift in Sources */, DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */, - DDB75A142A0593E2006ED576 /* OfflineTileManager.swift in Sources */, DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */, DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */, DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */, @@ -1562,6 +1548,7 @@ DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, + BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */, 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */, @@ -1805,7 +1792,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.22; + MARKETING_VERSION = 2.5.23; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1839,7 +1826,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.22; + MARKETING_VERSION = 2.5.23; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1871,7 +1858,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.22; + MARKETING_VERSION = 2.5.23; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1904,7 +1891,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.22; + MARKETING_VERSION = 2.5.23; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Assets.xcassets/THINKNODEM1.imageset/Contents.json b/Meshtastic/Assets.xcassets/THINKNODEM1.imageset/Contents.json new file mode 100644 index 00000000..7001ca9b --- /dev/null +++ b/Meshtastic/Assets.xcassets/THINKNODEM1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "thinknode_m1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic/Assets.xcassets/THINKNODEM1.imageset/thinknode_m1.svg b/Meshtastic/Assets.xcassets/THINKNODEM1.imageset/thinknode_m1.svg new file mode 100644 index 00000000..27e21a0b --- /dev/null +++ b/Meshtastic/Assets.xcassets/THINKNODEM1.imageset/thinknode_m1.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Assets.xcassets/THINKNODEM2.imageset/Contents.json b/Meshtastic/Assets.xcassets/THINKNODEM2.imageset/Contents.json new file mode 100644 index 00000000..81ee0ac1 --- /dev/null +++ b/Meshtastic/Assets.xcassets/THINKNODEM2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "thinknode_m2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic/Assets.xcassets/THINKNODEM2.imageset/thinknode_m2.svg b/Meshtastic/Assets.xcassets/THINKNODEM2.imageset/thinknode_m2.svg new file mode 100644 index 00000000..5e5a0e3c --- /dev/null +++ b/Meshtastic/Assets.xcassets/THINKNODEM2.imageset/thinknode_m2.svg @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Assets.xcassets/XIAONRF52KIT.imageset/Contents.json b/Meshtastic/Assets.xcassets/XIAONRF52KIT.imageset/Contents.json new file mode 100644 index 00000000..08990d2d --- /dev/null +++ b/Meshtastic/Assets.xcassets/XIAONRF52KIT.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "seeed_xiao_nrf52_kit.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic/Assets.xcassets/XIAONRF52KIT.imageset/seeed_xiao_nrf52_kit.svg b/Meshtastic/Assets.xcassets/XIAONRF52KIT.imageset/seeed_xiao_nrf52_kit.svg new file mode 100644 index 00000000..95f7211b --- /dev/null +++ b/Meshtastic/Assets.xcassets/XIAONRF52KIT.imageset/seeed_xiao_nrf52_kit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 740f04e2..ac13f24b 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -118,24 +118,6 @@ extension UserDefaults { @UserDefault(.enableMapPointsOfInterest, defaultValue: false) static var enableMapPointsOfInterest: Bool - @UserDefault(.enableOfflineMaps, defaultValue: false) - static var enableOfflineMaps: Bool - - @UserDefault(.mapTileServer, defaultValue: .openStreetMap) - static var mapTileServer: MapTileServer - - @UserDefault(.enableOverlayServer, defaultValue: false) - static var enableOverlayServer: Bool - - @UserDefault(.mapOverlayServer, defaultValue: .baseReReflectivityCurrent) - static var mapOverlayServer: MapOverlayServer - - @UserDefault(.mapTilesAboveLabels, defaultValue: false) - static var mapTilesAboveLabels: Bool - - @UserDefault(.mapUseLegacy, defaultValue: false) - static var mapUseLegacy: Bool - @UserDefault(.enableDetectionNotifications, defaultValue: false) static var enableDetectionNotifications: Bool diff --git a/Meshtastic/Helpers/Map/OfflineTileManager.swift b/Meshtastic/Helpers/Map/OfflineTileManager.swift deleted file mode 100644 index 66afa93c..00000000 --- a/Meshtastic/Helpers/Map/OfflineTileManager.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// OfflineTileManager.swift -// Meshtastic -// -// Copyright(c) Garth Vander Houwen 4/23/23. -// - -import Foundation -import MapKit -import OSLog - -class OfflineTileManager: ObservableObject { - static let shared = OfflineTileManager() - - // MARK: - Public properties - - @Published var status: DownloadStatus = .downloaded - - enum DownloadStatus { - case downloaded, downloading - } - - init() { - Logger.services.info("πŸ—‚οΈ Documents Directory = \(self.documentsDirectory.absoluteString, privacy: .public)") - createDirectoriesIfNecessary() - } - - // MARK: - Private properties - - private var overlay: MKTileOverlay { MKTileOverlay(urlTemplate: UserDefaults.mapTileServer.tileUrl.count > 1 ? UserDefaults.mapTileServer.tileUrl : MapTileServer.openStreetMap.tileUrl) } - private var documentsDirectory: URL { fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! } - private let fileManager = FileManager.default - - // MARK: - Public methods - - func getAllDownloadedSize() -> String { - fileManager.allocatedSizeOfDirectory(at: documentsDirectory.appendingPathComponent("tiles")) - } - - func removeAll() { - try? fileManager.removeItem(at: documentsDirectory.appendingPathComponent("tiles")) - createDirectoriesIfNecessary() - } - - func loadAndCacheTileOverlay(for path: MKTileOverlayPath) throws -> Data { - guard UserDefaults.enableOfflineMaps, UserDefaults.mapTileServer.zoomRange.contains(path.z) else { - return try Data(contentsOf: Bundle.main.url(forResource: "alpha", withExtension: "png")!) - } - - let tilesUrl = documentsDirectory - .appendingPathComponent("tiles") - .appendingPathComponent("\(UserDefaults.mapTileServer.id)-z\(path.z)x\(path.x)y\(path.y)") - .appendingPathExtension("png") - - do { - return try Data(contentsOf: tilesUrl) - } catch let error as NSError where error.code == NSFileReadNoSuchFileError { - DispatchQueue.main.async { self.status = .downloading } - defer { - DispatchQueue.main.async { self.status = .downloaded } - } - let data = try Data(contentsOf: overlay.url(forTilePath: path)) - try data.write(to: tilesUrl) - return data - } - } - - // MARK: Private methods - - private func createDirectoriesIfNecessary() { - let tiles = documentsDirectory.appendingPathComponent("tiles") - try? fileManager.createDirectory(at: tiles, withIntermediateDirectories: true, attributes: [:]) - } -} diff --git a/Meshtastic/Helpers/Map/TileOverlay.swift b/Meshtastic/Helpers/Map/TileOverlay.swift deleted file mode 100644 index 754771df..00000000 --- a/Meshtastic/Helpers/Map/TileOverlay.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// TileOverlay.swift -// Meshtastic -// -// Copyright(c) Garth Vander Houwen 5/5/23. -// - -import Foundation -import MapKit - -class TileOverlay: MKTileOverlay { - override func loadTile(at path: MKTileOverlayPath) async throws -> Data { - return try OfflineTileManager.shared.loadAndCacheTileOverlay(for: path) - } -} diff --git a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift index cac34de8..6dc1854b 100644 --- a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift +++ b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift @@ -8,6 +8,7 @@ import Foundation import CocoaMQTT import OSLog +import Security protocol MqttClientProxyManagerDelegate: AnyObject { func onMqttConnected() @@ -40,8 +41,8 @@ class MqttClientProxyManager { if let host = host { let port = defaultServerPort - var username = node.mqttConfig?.username - var password = node.mqttConfig?.password + let username = node.mqttConfig?.username + let password = node.mqttConfig?.password // if host == defaultServerAddress { //username = ProcessInfo.processInfo.environment["PUBLIC_MQTT_USERNAME"] //password = ProcessInfo.processInfo.environment["PUBLIC_MQTT_PASSWORD"] @@ -130,6 +131,16 @@ extension MqttClientProxyManager: CocoaMQTTDelegate { self.disconnect() } } + func mqtt(_ mqtt: CocoaMQTT, didReceive trust: SecTrust, completionHandler: @escaping (Bool) -> Void) { + let isValid = SecTrustEvaluateWithError(trust, nil) + if isValid { + Logger.mqtt.info("πŸ“² [MQTT Client Proxy] TLS validation succeeded.") + completionHandler(true) + } else { + Logger.mqtt.warning("πŸ“² [MQTT Client Proxy] TLS validation failed.") + completionHandler(true) + } + } func mqttDidDisconnect(_ mqtt: CocoaMQTT, withError err: Error?) { Logger.mqtt.debug("πŸ“² [MQTT Client Proxy] disconnected: \(err?.localizedDescription ?? "", privacy: .public)") if let error = err { diff --git a/Meshtastic/Persistence/Persistence.swift b/Meshtastic/Persistence/Persistence.swift index f18a8566..4b0fd147 100644 --- a/Meshtastic/Persistence/Persistence.swift +++ b/Meshtastic/Persistence/Persistence.swift @@ -45,6 +45,7 @@ class PersistenceController { // Merge policy that favors in memory data over data in the db self.container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy self.container.viewContext.automaticallyMergesChangesFromParent = true + self.container.viewContext.retainsRegisteredObjects = true if let error = error as NSError? { diff --git a/Meshtastic/Resources/DeviceHardware.json b/Meshtastic/Resources/DeviceHardware.json index 00b80aea..bbe99f2a 100644 --- a/Meshtastic/Resources/DeviceHardware.json +++ b/Meshtastic/Resources/DeviceHardware.json @@ -543,7 +543,7 @@ "images": [ "t-watch-s3.svg" ], - "partitionScheme": "16MB" + "partitionScheme": "8MB" }, { "hwModel": 52, @@ -845,25 +845,32 @@ "hwModelSlug": "THINKNODE_M1", "platformioTarget": "thinknode_m1", "architecture": "nrf52840", - "activelySupported": false, + "activelySupported": true, "supportLevel": 1, "displayName": "ThinkNode M1", "tags": [ "Elecrow" ], - "requiresDfu": true + "requiresDfu": true, + "images": [ + "thinknode_m1.svg" + ], + "hasInkHud": true }, { "hwModel": 90, "hwModelSlug": "THINKNODE_M2", "platformioTarget": "thinknode_m2", "architecture": "esp32-s3", - "activelySupported": false, + "activelySupported": true, "supportLevel": 1, "displayName": "ThinkNode M2", "tags": [ "Elecrow" ], - "requiresDfu": false + "requiresDfu": false, + "images": [ + "thinknode_m2.svg" + ] } ] diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index d6b2fd6b..50734893 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -11,6 +11,12 @@ struct ContentView: View { @ObservedObject var router: Router + init(appState: AppState, router: Router) { + self.appState = appState + self.router = router + UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) + } + var body: some View { TabView(selection: $appState.router.navigationState.selectedTab) { Messages( diff --git a/Meshtastic/Views/Helpers/CircleText.swift b/Meshtastic/Views/Helpers/CircleText.swift index b8f74842..c9ee41c6 100644 --- a/Meshtastic/Views/Helpers/CircleText.swift +++ b/Meshtastic/Views/Helpers/CircleText.swift @@ -4,29 +4,43 @@ A view draws a circle in the background of the shortName text */ import SwiftUI +import CoreData struct CircleText: View { - var text: String - var color: Color + var text: String + var color: Color var circleSize: CGFloat = 45 + var node: NodeInfoEntity? = nil + + var body: some View { + if let node = node { + NavigationStack{ + NavigationLink(destination: NodeDetail(node: node)) { + circleContent + } + } - var body: some View { + } else { + circleContent + } + } - ZStack { - Circle() - .fill(color) - .frame(width: circleSize, height: circleSize) - Text(text.addingVariationSelectors) + var circleContent: some View { + ZStack { + Circle() + .fill(color) + .frame(width: circleSize, height: circleSize) + Text(text) .frame(width: circleSize * 0.9, height: circleSize * 0.9, alignment: .center) .foregroundColor(color.isLight() ? .black : .white) .minimumScaleFactor(0.001) .font(.system(size: 1300)) - } - } + } + } } struct CircleText_Previews: PreviewProvider { - static var previews: some View { + static var previews: some View { VStack { HStack { CircleText(text: "N1", color: Color.yellow, circleSize: 80) @@ -75,5 +89,5 @@ struct CircleText_Previews: PreviewProvider { .previewLayout(.fixed(width: 300, height: 100)) } } - } + } } diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index a4fd86bf..426cb0c7 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -24,7 +24,7 @@ struct ChannelList: View { @State private var isPresentingTraceRouteSentAlert = false - var restrictedChannels = ["gpio", "mqtt", "serial"] + var restrictedChannels = ["gpio", "mqtt", "serial", "admin"] @ViewBuilder private func makeChannelRow( diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index bf5be325..0696e9d8 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -22,128 +22,183 @@ struct ChannelMessageList: View { @ObservedObject var channel: ChannelEntity @State private var replyMessageId: Int64 = 0 @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 + + // Scroll state + @State private var showScrollToBottomButton = false + @State private var hasReachedBottom = false + @State private var gotFirstUnreadMessage: Bool = false var body: some View { VStack { ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach( channel.allPrivateMessages ) { (message: MessageEntity) in - let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) - if message.replyID > 0 { - let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID }) - HStack { - Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.blue, lineWidth: 0.5) - ) - Image(systemName: "arrowshape.turn.up.left.fill") - .symbolRenderingMode(.hierarchical) - .imageScale(.large).foregroundColor(.accentColor) - .padding(.trailing) - } - } - HStack(alignment: .bottom) { - if currentUser { Spacer(minLength: 50) } - if !currentUser { - CircleText(text: message.fromUser?.shortName ?? "?", color: Color(UIColor(hex: UInt32(message.fromUser?.num ?? 0))), circleSize: 44) - .padding(.all, 5) - .offset(y: -7) - } - - VStack(alignment: currentUser ? .trailing : .leading) { - let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) - - if !currentUser && message.fromUser != nil { - Text("\(message.fromUser?.longName ?? "unknown".localized ) (\(message.fromUser?.userId ?? "?"))") - .font(.caption) - .foregroundColor(.gray) - .offset(y: 8) - } - + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack { + ForEach(channel.allPrivateMessages) { (message: MessageEntity) in + let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) + if message.replyID > 0 { + let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID }) HStack { - MessageText( - message: message, - tapBackDestination: .channel(channel), - isCurrentUser: currentUser - ) { - self.replyMessageId = message.messageId - self.messageFieldFocused = true - } - - if currentUser && message.canRetry { - RetryButton(message: message, destination: .channel(channel)) - } - } - - TapbackResponses(message: message) { - appState.unreadChannelMessages = myInfo.unreadMessages - context.refresh(myInfo, mergeChanges: true) - } - - HStack { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - if currentUser && message.receivedACK { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - .foregroundStyle(ackErrorVal?.color ?? Color.red) - .font(.caption2) - } else if currentUser && message.ackError == 0 { - // Empty Error - Text("Waiting to be acknowledged. . .").font( - .caption2) - .foregroundColor(.orange) - } else if currentUser && !isDetectionSensorMessage { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - .foregroundStyle(ackErrorVal?.color ?? Color.red) - .font(.caption2) - } + Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.blue, lineWidth: 0.5) + ) + Image(systemName: "arrowshape.turn.up.left.fill") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.accentColor) + .padding(.trailing) } } - .padding(.bottom) - .id(channel.allPrivateMessages.firstIndex(of: message)) + HStack(alignment: .bottom) { + if currentUser { Spacer(minLength: 50) } + if !currentUser { + CircleText(text: message.fromUser?.shortName ?? "?", color: Color(UIColor(hex: UInt32(message.fromUser?.num ?? 0))), circleSize: 44, node: getNodeInfo(id: Int64(message.fromUser?.num ?? 0), context: context)) + .padding(.all, 5) + .offset(y: -7) + } - if !currentUser { - Spacer(minLength: 50) - } - } - .padding([.leading, .trailing]) - .frame(maxWidth: .infinity) - .id(message.messageId) - .onAppear { - if !message.read { - message.read = true - do { - for unreadMessage in channel.allPrivateMessages.filter({ !$0.read }) { - unreadMessage.read = true + VStack(alignment: currentUser ? .trailing : .leading) { + let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + + if !currentUser && message.fromUser != nil { + Text("\(message.fromUser?.longName ?? "unknown".localized ) (\(message.fromUser?.userId ?? "?"))") + .font(.caption) + .foregroundColor(.gray) + .offset(y: 8) + } + + HStack { + MessageText( + message: message, + tapBackDestination: .channel(channel), + isCurrentUser: currentUser + ) { + self.replyMessageId = message.messageId + self.messageFieldFocused = true + } + + if currentUser && message.canRetry { + RetryButton(message: message, destination: .channel(channel)) + } + } + + TapbackResponses(message: message) { + appState.unreadChannelMessages = myInfo.unreadMessages + context.refresh(myInfo, mergeChanges: true) + } + + HStack { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + if currentUser && message.receivedACK { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) + .foregroundStyle(ackErrorVal?.color ?? Color.red) + .font(.caption2) + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting to be acknowledged. . .").font( + .caption2) + .foregroundColor(.orange) + } else if currentUser && !isDetectionSensorMessage { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) + .foregroundStyle(ackErrorVal?.color ?? Color.red) + .font(.caption2) + } + } + } + .padding(.bottom) + .id(channel.allPrivateMessages.firstIndex(of: message)) + + if !currentUser { + Spacer(minLength: 50) + } + } + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + .id(message.messageId) + .onAppear { + if gotFirstUnreadMessage{ + if !message.read { + message.read = true + do { + for unreadMessage in channel.allPrivateMessages.filter({ !$0.read }) { + unreadMessage.read = true + } + try context.save() + Logger.data.info("πŸ“– [App] Read message \(message.messageId, privacy: .public) ") + appState.unreadChannelMessages = myInfo.unreadMessages + context.refresh(myInfo, mergeChanges: true) + } catch { + Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + // Check if we've reached the bottom message + if message.messageId == channel.allPrivateMessages.last?.messageId { + hasReachedBottom = true + showScrollToBottomButton = false } - try context.save() - Logger.data.info("πŸ“– [App] Read message \(message.messageId, privacy: .public) ") - appState.unreadChannelMessages = myInfo.unreadMessages - context.refresh(myInfo, mergeChanges: true) - } catch { - Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") } } } + // Invisible spacer to detect reaching bottom + Color.clear + .frame(height: 1) + .id("bottomAnchor") + .onAppear { + hasReachedBottom = true + showScrollToBottomButton = false + } } } - } - .scrollDismissesKeyboard(.interactively) - .onFirstAppear { - withAnimation { - scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + .scrollDismissesKeyboard(.interactively) + .onFirstAppear { + // Find first unread message + if let firstUnreadMessageId = channel.allPrivateMessages.first(where: { !$0.read })?.messageId { + withAnimation { + scrollView.scrollTo(firstUnreadMessageId, anchor: .top) + showScrollToBottomButton = true + } + } else { + // If no unread messages, scroll to bottom + withAnimation { + scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + } + } + gotFirstUnreadMessage = true } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in - withAnimation { - scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + withAnimation { + scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + showScrollToBottomButton = false + } } - } - .onChange(of: channel.allPrivateMessages) { - withAnimation { - scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + .onChange(of: channel.allPrivateMessages) { + if hasReachedBottom { + withAnimation { + scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + } + } else { + showScrollToBottomButton = true + } + } + + // Scroll to bottom button + if showScrollToBottomButton { + Button { + withAnimation { + scrollView.scrollTo("bottomAnchor", anchor: .bottom) + hasReachedBottom = true + showScrollToBottomButton = false + } + } label: { + ScrollToBottomButtonView() + } + .padding(.bottom, 8) + .padding(.trailing, 16) + .transition(.opacity) } } } diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index dea4586f..6f995756 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -20,115 +20,172 @@ struct UserMessageList: View { // View State Items @ObservedObject var user: UserEntity @State private var replyMessageId: Int64 = 0 + + // Scroll state + @State private var showScrollToBottomButton = false + @State private var hasReachedBottom = false + @State private var gotFirstUnreadMessage: Bool = false var body: some View { VStack { ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach( user.messageList ) { (message: MessageEntity) in - if user.num != bleManager.connectedPeripheral?.num ?? -1 { - let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack { + ForEach( user.messageList ) { (message: MessageEntity) in + if user.num != bleManager.connectedPeripheral?.num ?? -1 { + let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) - if message.replyID > 0 { - let messageReply = user.messageList.first(where: { $0.messageId == message.replyID }) - HStack { - Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.blue, lineWidth: 0.5) - ) - Image(systemName: "arrowshape.turn.up.left.fill") - .symbolRenderingMode(.hierarchical) - .imageScale(.large).foregroundColor(.accentColor) - .padding(.trailing) + if message.replyID > 0 { + let messageReply = user.messageList.first(where: { $0.messageId == message.replyID }) + HStack { + Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.blue, lineWidth: 0.5) + ) + Image(systemName: "arrowshape.turn.up.left.fill") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.accentColor) + .padding(.trailing) + } } - } - HStack(alignment: .top) { - if currentUser { Spacer(minLength: 50) } - VStack(alignment: currentUser ? .trailing : .leading) { - HStack { - MessageText( - message: message, - tapBackDestination: .user(user), - isCurrentUser: currentUser - ) { - self.replyMessageId = message.messageId - self.messageFieldFocused = true - } - - if currentUser && message.canRetry || (message.receivedACK && !message.realACK) { - RetryButton(message: message, destination: .user(user)) - } - } - - TapbackResponses(message: message) { - appState.unreadDirectMessages = user.unreadMessages - } - - HStack { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - if currentUser && message.receivedACK { - // Ack Received - if message.realACK { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")") - .font(.caption2) - .foregroundStyle(ackErrorVal?.color ?? Color.secondary) - } else { - Text("Acknowledged by another node").font(.caption2).foregroundColor(.orange) + HStack(alignment: .top) { + if currentUser { Spacer(minLength: 50) } + VStack(alignment: currentUser ? .trailing : .leading) { + HStack { + MessageText( + message: message, + tapBackDestination: .user(user), + isCurrentUser: currentUser + ) { + self.replyMessageId = message.messageId + self.messageFieldFocused = true + } + + if currentUser && message.canRetry || (message.receivedACK && !message.realACK) { + RetryButton(message: message, destination: .user(user)) + } + } + + TapbackResponses(message: message) { + appState.unreadDirectMessages = user.unreadMessages + } + + HStack { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + if currentUser && message.receivedACK { + // Ack Received + if message.realACK { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")") + .font(.caption2) + .foregroundStyle(ackErrorVal?.color ?? Color.secondary) + } else { + Text("Acknowledged by another node").font(.caption2).foregroundColor(.orange) + } + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.yellow) + } else if currentUser && message.ackError > 0 { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) + .foregroundStyle(ackErrorVal?.color ?? Color.red) + .font(.caption2) } - } else if currentUser && message.ackError == 0 { - // Empty Error - Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.yellow) - } else if currentUser && message.ackError > 0 { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - .foregroundStyle(ackErrorVal?.color ?? Color.red) - .font(.caption2) } } - } - .padding(.bottom) - .id(user.messageList.firstIndex(of: message)) + .padding(.bottom) + .id(user.messageList.firstIndex(of: message)) - if !currentUser { - Spacer(minLength: 50) + if !currentUser { + Spacer(minLength: 50) + } } - } - .padding([.leading, .trailing]) - .frame(maxWidth: .infinity) - .id(message.messageId) - .onAppear { - if !message.read { - message.read = true - do { - try context.save() - Logger.data.info("πŸ“– [App] Read message \(message.messageId, privacy: .public) ") - appState.unreadDirectMessages = user.unreadMessages - - } catch { - Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + .id(message.messageId) + .onAppear { + if gotFirstUnreadMessage { + if !message.read { + message.read = true + do { + for unreadMessage in user.messageList.filter({ !$0.read }) { + unreadMessage.read = true + } + try context.save() + Logger.data.info("πŸ“– [App] Read message \(message.messageId, privacy: .public) ") + appState.unreadDirectMessages = user.unreadMessages + } catch { + Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + // Check if we've reached the bottom message + if message.messageId == user.messageList.last?.messageId { + hasReachedBottom = true + showScrollToBottomButton = false + } } } } } + // Invisible spacer to detect reaching bottom + Color.clear + .frame(height: 1) + .id("bottomAnchor") + .onAppear { + hasReachedBottom = true + showScrollToBottomButton = false + } } } - } - .scrollDismissesKeyboard(.interactively) - .onFirstAppear { - withAnimation { - scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + .scrollDismissesKeyboard(.interactively) + .onFirstAppear { + // Find first unread message + if let firstUnreadMessageId = user.messageList.first(where: { !$0.read })?.messageId { + withAnimation { + scrollView.scrollTo(firstUnreadMessageId, anchor: .top) + showScrollToBottomButton = true + } + } else { + // If no unread messages, scroll to bottom + withAnimation { + scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + } + } + gotFirstUnreadMessage = true } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in - withAnimation { - scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + withAnimation { + scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + showScrollToBottomButton = false + } } - } - .onChange(of: user.messageList) { - withAnimation { - scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + .onChange(of: user.messageList) { + if hasReachedBottom { + withAnimation { + scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + } + } else { + showScrollToBottomButton = true + } + } + + // Scroll to bottom button + if showScrollToBottomButton { + Button { + withAnimation { + scrollView.scrollTo("bottomAnchor", anchor: .bottom) + hasReachedBottom = true + showScrollToBottomButton = false + } + } label: { + ScrollToBottomButtonView() + } + .padding(.bottom, 8) + .padding(.trailing, 16) + .transition(.opacity) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index f68354c0..880a4074 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -23,10 +23,10 @@ struct PositionPopover: View { var body: some View { // Node Color from node.num let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) + NavigationStack{ VStack { HStack { ZStack { - if position.nodePosition?.isOnline ?? false { Circle() .fill(Color(nodeColor.lighter()).opacity(0.4).shadow(.drop(color: Color(nodeColor).isLight() ? .black : .white, radius: 5))) @@ -34,16 +34,15 @@ struct PositionPopover: View { .scaleEffect(scale) .animation( Animation.easeInOut(duration: 0.6) - .repeatForever().delay(delay), value: scale + .repeatForever().delay(delay), value: scale ) .onAppear { self.scale = 1 } .frame(width: 90, height: 90) } - CircleText(text: position.nodePosition?.user?.shortName ?? "?", color: Color(nodeColor), circleSize: 65) + CircleText(text: position.nodePosition?.user?.shortName ?? "?", color: Color(nodeColor), circleSize: 65, node: getNodeInfo(id: Int64(position.nodePosition?.user?.num ?? 0), context: context)) } - Text(position.nodePosition?.user?.longName ?? "Unknown") .font(.largeTitle) } @@ -106,7 +105,7 @@ struct PositionPopover: View { .foregroundColor(.primary) .font(idiom == .phone ? .callout : .body) } - + } icon: { Image(systemName: "mountain.2.fill") .symbolRenderingMode(.hierarchical) @@ -147,9 +146,9 @@ struct PositionPopover: View { Text("Heading: \(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))") } icon: { Image(systemName: "location.north") - .symbolRenderingMode(.hierarchical) - .frame(width: 35) - .rotationEffect(degrees) + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + .rotationEffect(degrees) } .padding(.bottom, 5) /// Distance @@ -181,15 +180,15 @@ struct PositionPopover: View { } .padding(.bottom, 5) if position.nodePosition?.viaMqtt ?? false { - + Label { Text("MQTT") .font(idiom == .phone ? .callout : .body) } icon: { Image(systemName: "network") - .symbolRenderingMode(.hierarchical) - .frame(width: 35) - .rotationEffect(degrees) + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + .rotationEffect(degrees) } .padding(.bottom, 5) } @@ -244,6 +243,7 @@ struct PositionPopover: View { #endif } } + } .presentationDetents([.fraction(0.65), .large]) .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index e48a9acc..10c0b569 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -520,6 +520,7 @@ struct NodeDetail: View { } } .listStyle(.insetGrouped) + .navigationBarTitle(String(node.user?.longName?.addingVariationSelectors ?? "unknown".localized), displayMode: .inline) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/ScrollToBottomButton.swift b/Meshtastic/Views/Nodes/Helpers/ScrollToBottomButton.swift new file mode 100644 index 00000000..da10d18a --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/ScrollToBottomButton.swift @@ -0,0 +1,30 @@ +// +// ScrollToBottomButtonView.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 4/2/25. +// + +import SwiftUI + +struct ScrollToBottomButtonView: View { + var body: some View { + HStack(spacing: 4) { + Text("Jump to present") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .cornerRadius(12) + Image(systemName: "arrow.down") + .font(.title2) + .symbolRenderingMode(.hierarchical) + + } + .foregroundColor(.accentColor) + .shadow(radius: 2) + } +} + +#Preview { + ScrollToBottomButtonView() +} diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 34dbd475..49b2d3a1 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -264,7 +264,6 @@ struct NodeList: View { columnVisibility: columnVisibility ) .edgesIgnoringSafeArea([.leading, .trailing]) - .navigationBarTitle(String(node.user?.longName?.addingVariationSelectors ?? "unknown".localized), displayMode: .inline) .navigationBarItems( trailing: ZStack { if UIDevice.current.userInterfaceIdiom != .phone { diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index d8d1a1e8..7ba7d3f9 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -8,7 +8,6 @@ import OSLog struct AppSettings: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @ObservedObject var tileManager = OfflineTileManager.shared @State var totalDownloadedTileSize = "" @State private var isPresentingCoreDataResetConfirm = false @State private var isPresentingDeleteMapTilesConfirm = false @@ -85,31 +84,7 @@ struct AppSettings: View { .foregroundColor(.red) } } - if totalDownloadedTileSize != "0MB" { - Section(header: Text("Map Tile Data")) { - Button { - isPresentingDeleteMapTilesConfirm = true - } label: { - Label("\("map.tiles.delete".localized) (\(totalDownloadedTileSize))", systemImage: "trash") - .foregroundColor(.red) - } - .confirmationDialog( - "Are you sure?", - isPresented: $isPresentingDeleteMapTilesConfirm, - titleVisibility: .visible - ) { - Button("Delete all map tiles?", role: .destructive) { - tileManager.removeAll() - totalDownloadedTileSize = tileManager.getAllDownloadedSize() - Logger.services.debug("delete all tiles") - } - } - } - } } - .onAppear(perform: { - totalDownloadedTileSize = tileManager.getAllDownloadedSize() - }) } .navigationTitle("App Settings") .navigationBarItems(trailing: diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index 63fdf327..82b4f628 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -29,9 +29,9 @@ struct BluetoothConfig: View { Form { ConfigHeader(title: "Bluetooth", config: \.bluetoothConfig, node: node, onAppear: setBluetoothValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "antenna.radiowaves.left.and.right") + Label("Enabled", systemImage: "antenna.radiowaves.left.and.right") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) Picker("Pairing Mode", selection: $mode ) { diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index abce6e9a..3fdc73b9 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -40,7 +40,7 @@ struct DeviceConfig: View { Form { ConfigHeader(title: "Device", config: \.deviceConfig, node: node, onAppear: setDeviceValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { VStack(alignment: .leading) { Picker("Device Role", selection: $deviceRole ) { ForEach(DeviceRoles.allCases) { dr in diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index 8e3a0f02..24c0ada3 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -30,7 +30,7 @@ struct AmbientLightingConfig: View { Form { ConfigHeader(title: "Ambient Lighting", config: \.ambientLightingConfig, node: node, onAppear: setAmbientLightingConfigValue) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $ledState) { Label("LED State", systemImage: ledState ? "lightbulb.led.fill" : "lightbulb.led") diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 99a28a32..e95d524f 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -42,11 +42,11 @@ struct CannedMessagesConfig: View { Form { ConfigHeader(title: "Canned messages", config: \.cannedMessageConfig, node: node, onAppear: setCannedMessagesValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "list.bullet.rectangle.fill") + Label("Enabled", systemImage: "list.bullet.rectangle.fill") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index 1c53b495..21e24177 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -47,10 +47,10 @@ struct DetectionSensorConfig: View { Form { ConfigHeader(title: "Detection Sensor", config: \.detectionSensorConfig, node: node, onAppear: setDetectionSensorValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "dot.radiowaves.right") + Label("Enabled", systemImage: "dot.radiowaves.right") Text("Enables the detection sensor module, it needs to be enabled on both the node with the sensor, and any nodes that you want to receive detection sensor text messages or view the detection sensor log and chart.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index 9602c44b..01d2f247 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -39,10 +39,10 @@ struct ExternalNotificationConfig: View { Form { ConfigHeader(title: "External notification", config: \.externalNotificationConfig, node: node, onAppear: setExternalNotificationValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "megaphone") + Label("Enabled", systemImage: "megaphone") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 29b06464..21297a16 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -30,6 +30,7 @@ struct MQTTConfig: View { @State var mqttConnected: Bool = false @State var defaultTopic = "msh/US" @State var nearbyTopics = [String]() + @State var mapReportingOptIn = false @State var mapReportingEnabled = false @State var mapPublishIntervalSecs = 3600 @State var mapPositionPrecision: Double = 14.0 @@ -50,10 +51,10 @@ struct MQTTConfig: View { ConfigHeader(title: "MQTT", config: \.mqttConfig, node: node, onAppear: setMqttValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "dot.radiowaves.up.forward") + Label("Enabled", systemImage: "dot.radiowaves.up.forward") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) @@ -66,7 +67,7 @@ struct MQTTConfig: View { if enabled && proxyToClientEnabled && node?.mqttConfig?.proxyToClientEnabled ?? false == true { Toggle(isOn: $mqttConnected) { - Label(mqttConnected ? "mqtt.disconnect".localized : "mqtt.connect".localized, systemImage: "server.rack") + Label("Connect to MQTT via Proxy", systemImage: "server.rack") if bleManager.mqttError.count > 0 { Text(bleManager.mqttError) .fixedSize(horizontal: false, vertical: true) @@ -92,12 +93,30 @@ struct MQTTConfig: View { } Section(header: Text("Map Report")) { - Toggle(isOn: $mapReportingEnabled) { - Label("enabled", systemImage: "map") + Label("Enabled", systemImage: "map") + Text("Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name.") + .foregroundColor(.gray) + .font(.caption) } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) if mapReportingEnabled { + Text("Consent to Share Unencrypted Node Data via MQTT") + Text("By enabling this feature, you acknowledge and expressly consent to the transmission of your device’s real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions.") + .foregroundColor(.gray) + .font(.caption) + Text("Please be advised that because the map report is not encrypted, your data may be stored and displayed permanently by third parties. Meshtastic does not assume responsibility for any such storage, display or disclosure of this data.") + .foregroundColor(.gray) + .font(.caption) + Toggle(isOn: $mapReportingOptIn) { + Label("I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.", systemImage: "hand.raised") + .foregroundColor(.gray) + .font(.callout) + + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + if mapReportingEnabled && mapReportingOptIn { Picker("Map Publish Interval", selection: $mapPublishIntervalSecs ) { ForEach(UpdateIntervals.allCases) { ui in if ui.rawValue >= 3600 { @@ -108,6 +127,9 @@ struct MQTTConfig: View { .pickerStyle(DefaultPickerStyle()) VStack(alignment: .leading) { Label("Approximate Location", systemImage: "location.slash.circle.fill") + Text("To comply with privacy laws like CCPA and GDPR, we avoid sharing exact location data. Instead, we use anonymized or approximate (imprecise) location information to protect your privacy.") + .foregroundColor(.gray) + .font(.callout) Slider(value: $mapPositionPrecision, in: 11...14, step: 1) { } minimumValueLabel: { Image(systemName: "minus") @@ -178,8 +200,8 @@ struct MQTTConfig: View { .autocorrectionDisabled() if address != "mqtt.meshtastic.org" { HStack { - Label("mqtt.username", systemImage: "person.text.rectangle") - TextField("mqtt.username", text: $username) + Label("Username", systemImage: "person.text.rectangle") + TextField("Username", text: $username) .foregroundColor(.gray) .autocapitalization(.none) .disableAutocorrection(true) @@ -197,8 +219,8 @@ struct MQTTConfig: View { .keyboardType(.default) .scrollDismissesKeyboard(.interactively) HStack { - Label("password", systemImage: "wallet.pass") - TextField("password", text: $password) + Label("Password", systemImage: "wallet.pass") + TextField("Password", text: $password) .foregroundColor(.gray) .autocapitalization(.none) .disableAutocorrection(true) @@ -244,7 +266,7 @@ struct MQTTConfig: View { mqtt.encryptionEnabled = self.encryptionEnabled mqtt.jsonEnabled = self.jsonEnabled mqtt.tlsEnabled = self.tlsEnabled - mqtt.mapReportingEnabled = self.mapReportingEnabled + mqtt.mapReportingEnabled = (self.mapReportingEnabled && self.mapReportingOptIn) mqtt.mapReportSettings.positionPrecision = UInt32(self.mapPositionPrecision) mqtt.mapReportSettings.publishIntervalSecs = UInt32(self.mapPublishIntervalSecs) let adminMessageId = bleManager.saveMQTTConfig(config: mqtt, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) @@ -266,6 +288,10 @@ struct MQTTConfig: View { if newProxyToClientEnabled != node?.mqttConfig?.proxyToClientEnabled { hasChanges = true } } .onChange(of: address) { _, newAddress in + if address.lowercased() == "mqtt.meshtastic.org" { + username = "meshdev" + password = "large4cats" + } if newAddress != node?.mqttConfig?.address ?? "" { hasChanges = true } } .onChange(of: username) { _, newUsername in diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index 24af3504..5f78379e 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -26,7 +26,7 @@ struct PaxCounterConfig: View { Section { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "figure.walk.motion") + Label("Enabled", systemImage: "figure.walk.motion") Text("config.module.paxcounter.enabled.description") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) @@ -46,7 +46,7 @@ struct PaxCounterConfig: View { .font(.callout) } } header: { - Text("options") + Text("Options") } } .disabled(self.bleManager.connectedPeripheral == nil || node?.powerConfig == nil) diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index 979eb736..7ac35423 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -27,9 +27,9 @@ struct RangeTestConfig: View { Form { ConfigHeader(title: "Range", config: \.rangeTestConfig, node: node, onAppear: setRangeTestValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "figure.walk") + Label("Enabled", systemImage: "figure.walk") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .listRowSeparator(.visible) diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index b81e2348..669add34 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -24,7 +24,7 @@ struct RtttlConfig: View { Form { ConfigHeader(title: "ringtone", config: \.rtttlConfig, node: node, onAppear: setRtttLConfigValue) - Section(header: Text("options")) { + Section(header: Text("Options")) { HStack { Label("ringtone", systemImage: "music.quarternote.3") TextField("config.ringtone.label", text: $ringtone, axis: .vertical) diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index fd2c0c99..d8ca379d 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -33,10 +33,10 @@ struct SerialConfig: View { Form { ConfigHeader(title: "Serial", config: \.serialConfig, node: node, onAppear: setSerialValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "terminal") + Label("Enabled", systemImage: "terminal") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index fe6abcd1..3f2e66f6 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -34,9 +34,9 @@ struct StoreForwardConfig: View { Form { ConfigHeader(title: "Store & Forward", config: \.storeForwardConfig, node: node, onAppear: setStoreAndForwardValues) - Section(header: Text("options")) { + Section(header: Text("Options")) { Toggle(isOn: $enabled) { - Label("enabled", systemImage: "envelope.arrow.triangle.branch") + Label("Enabled", systemImage: "envelope.arrow.triangle.branch") Text("Enables the store and forward module.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index ad173dae..6d78316c 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -64,7 +64,7 @@ struct TelemetryConfig: View { .foregroundColor(.gray) .font(.callout) Toggle(isOn: $environmentMeasurementEnabled) { - Label("enabled", systemImage: "chart.xyaxis.line") + Label("Enabled", systemImage: "chart.xyaxis.line") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) Toggle(isOn: $environmentScreenEnabled) { @@ -78,7 +78,7 @@ struct TelemetryConfig: View { } Section(header: Text("Power Options")) { Toggle(isOn: $powerMeasurementEnabled) { - Label("enabled", systemImage: "bolt") + Label("Enabled", systemImage: "bolt") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .listRowSeparator(.visible) diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index c63e95be..b8e51fe3 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -36,7 +36,7 @@ struct NetworkConfig: View { Section(header: Text("WiFi Options")) { Toggle(isOn: $wifiEnabled) { - Label("enabled", systemImage: "wifi") + Label("Enabled", systemImage: "wifi") Text("Enabling WiFi will disable the bluetooth connection to the app.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) @@ -82,7 +82,7 @@ struct NetworkConfig: View { if node.metadata?.hasEthernet ?? false { Section(header: Text("Ethernet Options")) { Toggle(isOn: $ethEnabled) { - Label("enabled", systemImage: "network") + Label("Enabled", systemImage: "network") Text("Enabling Ethernet will disable the bluetooth connection to the app.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) @@ -92,7 +92,7 @@ struct NetworkConfig: View { if node.metadata?.hasEthernet ?? false || node.metadata?.hasWifi ?? false { Section(header: Text("UDP Broadcast")) { Toggle(isOn: $udpEnabled) { - Label("enabled", systemImage: "point.3.connected.trianglepath.dotted") + Label("Enabled", systemImage: "point.3.connected.trianglepath.dotted") Text("Enable broadcasting packets via UDP over the local network.") } } diff --git a/Meshtastic/Views/Settings/Routes.swift b/Meshtastic/Views/Settings/Routes.swift index 52b00fa0..7e6407bc 100644 --- a/Meshtastic/Views/Settings/Routes.swift +++ b/Meshtastic/Views/Settings/Routes.swift @@ -184,7 +184,7 @@ struct Routes: View { } Toggle(isOn: $enabled) { - Label("enabled", systemImage: "point.topleft.filled.down.to.point.bottomright.curvepath") + Label("Enabled", systemImage: "point.topleft.filled.down.to.point.bottomright.curvepath") Text("Show on the mesh map.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 231efec2..3923fee7 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -31,23 +31,21 @@ struct WidgetsLiveActivity: Widget { } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - if context.state.totalNodes >= 100 { - Text("100+ online") + if context.state.totalNodes > 0 { + Text(" \(context.state.nodesOnline) online") .font(.callout) .foregroundStyle(.secondary) .fixedSize() } else { - Text("\(context.state.nodesOnline) of \(context.state.totalNodes) online") + Text(" ") .font(.callout) .foregroundStyle(.secondary) .fixedSize() } - // Text("\(context.state.channelUtilization.map { String(format: "Ch. Util: %.2f", $0) } ?? "--")%") Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - // Text("\(context.state.airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") Text("Airtime: \(context.state.airtime?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) @@ -166,7 +164,7 @@ struct LiveActivityView: View { .frame(minWidth: 25, idealWidth: 45, maxWidth: 55) Spacer() NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets, - dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, totalNodes: totalNodes, timerRange: timerRange) + dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, timerRange: timerRange) Spacer() } .tint(.primary) @@ -191,7 +189,6 @@ struct NodeInfoView: View { var packetsSentRelay: UInt32 var packetsCanceledRelay: UInt32 var nodesOnline: UInt32 - var totalNodes: UInt32 var timerRange: ClosedRange var body: some View { @@ -220,21 +217,14 @@ struct NodeInfoView: View { .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() - if totalNodes >= 100 { - Text("Connected: \(nodesOnline) nodes online") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } else { - Text("Connected: \(nodesOnline) of \(totalNodes) nodes online") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } + + Text("Connected: \(nodesOnline) nodes online") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .opacity(isLuminanceReduced ? 0.8 : 1.0) + .fixedSize() + let now = Date() Text("Last Heard: \(now.formatted())") .font(.caption)