diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 181d0bd8..b1d8fa0a 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */; }; DD0D3D222A55CEB10066DB71 /* CocoaMQTT in Frameworks */ = {isa = PBXBuildFile; productRef = DD0D3D212A55CEB10066DB71 /* CocoaMQTT */; }; DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */; }; + DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */; }; DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */; }; DD1925B928CDA93900720036 /* SerialConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B828CDA93900720036 /* SerialConfigEnums.swift */; }; DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; @@ -67,6 +68,7 @@ DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */; }; DD6193792863875F00E59241 /* SerialConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193782863875F00E59241 /* SerialConfig.swift */; }; DD73FD1128750779000852D6 /* PositionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FD1028750779000852D6 /* PositionLog.swift */; }; + DD760AAE2ABAC706002C022E /* WaypointPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD760AAD2ABAC706002C022E /* WaypointPopover.swift */; }; DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */; }; DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */; }; DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */; }; @@ -218,6 +220,7 @@ DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityExtension.swift; sourceTree = ""; }; DD0E9C222A30CE3A00580CBB /* MeshtasticDataModelV14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV14.xcdatamodel; sourceTree = ""; }; DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminMessageList.swift; sourceTree = ""; }; + DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionPopover.swift; sourceTree = ""; }; DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV15.xcdatamodel; sourceTree = ""; }; DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfigEnums.swift; sourceTree = ""; }; DD1925B828CDA93900720036 /* SerialConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfigEnums.swift; sourceTree = ""; }; @@ -227,6 +230,7 @@ DD2553562855B02500E55709 /* LoRaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoRaConfig.swift; sourceTree = ""; }; DD2553582855B52700E55709 /* PositionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionConfig.swift; sourceTree = ""; }; DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewSwiftUI.swift; sourceTree = ""; }; + DD2CC2E52ABFE04E00EDFDA7 /* MeshtasticDataModelV19.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV19.xcdatamodel; sourceTree = ""; }; DD2DC2BF29BCD8AB003B383C /* HardwareModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareModels.swift; sourceTree = ""; }; DD2E65252767A01F00E45FC5 /* NodeDetailOld.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDetailOld.swift; sourceTree = ""; }; DD3501882852FC3B000FC853 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; @@ -274,6 +278,7 @@ DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfig.swift; sourceTree = ""; }; DD6193782863875F00E59241 /* SerialConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfig.swift; sourceTree = ""; }; DD73FD1028750779000852D6 /* PositionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionLog.swift; sourceTree = ""; }; + DD760AAD2ABAC706002C022E /* WaypointPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointPopover.swift; sourceTree = ""; }; DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMetricsLog.swift; sourceTree = ""; }; DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTips.swift; sourceTree = ""; }; DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = ""; }; @@ -819,6 +824,8 @@ DDDB26412AABF655003AFCB7 /* NodeListItem.swift */, DDDB26472AACD6D1003AFCB7 /* NodeMapControl.swift */, DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */, + DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */, + DD760AAD2ABAC706002C022E /* WaypointPopover.swift */, ); path = Helpers; sourceTree = ""; @@ -1069,6 +1076,7 @@ DDDB443629F6287000EE2349 /* MapButtons.swift in Sources */, DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, + DD760AAE2ABAC706002C022E /* WaypointPopover.swift in Sources */, DD5E5203298EE33B00D21B61 /* config.pb.swift in Sources */, DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, DDA6B2EB28420A7B003E8C16 /* NodeAnnotation.swift in Sources */, @@ -1106,6 +1114,7 @@ DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */, DD5E5209298EE33B00D21B61 /* module_config.pb.swift in Sources */, DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */, + DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */, 6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */, DDDB444229F8A88700EE2349 /* Double.swift in Sources */, DD5E520F298EE33B00D21B61 /* cannedmessages.pb.swift in Sources */, @@ -1410,7 +1419,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2.6; + MARKETING_VERSION = 2.2.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1444,7 +1453,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2.6; + MARKETING_VERSION = 2.2.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1566,7 +1575,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.6; + MARKETING_VERSION = 2.2.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1599,7 +1608,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.6; + MARKETING_VERSION = 2.2.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1710,6 +1719,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD2CC2E52ABFE04E00EDFDA7 /* MeshtasticDataModelV19.xcdatamodel */, DDDB26492AAD743E003AFCB7 /* MeshtasticDataModelV18.xcdatamodel */, DDF6B2462A9AEB9E00BA6931 /* MeshtasticDataModelV17.xcdatamodel */, DDC4CA012A8DAA3800CE201C /* MeshtasticDataModelV16.xcdatamodel */, @@ -1729,7 +1739,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DDDB26492AAD743E003AFCB7 /* MeshtasticDataModelV18.xcdatamodel */; + currentVersion = DD2CC2E52ABFE04E00EDFDA7 /* MeshtasticDataModelV19.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Enums/AppSettingsEnums.swift b/Meshtastic/Enums/AppSettingsEnums.swift index d2d13979..dd0a94ed 100644 --- a/Meshtastic/Enums/AppSettingsEnums.swift +++ b/Meshtastic/Enums/AppSettingsEnums.swift @@ -85,7 +85,6 @@ enum UserTrackingModes: Int, CaseIterable, Identifiable { } enum LocationUpdateInterval: Int, CaseIterable, Identifiable { - case fiveSeconds = 5 case tenSeconds = 10 case fifteenSeconds = 15 case thirtySeconds = 30 @@ -97,8 +96,6 @@ enum LocationUpdateInterval: Int, CaseIterable, Identifiable { var id: Int { self.rawValue } var description: String { switch self { - case .fiveSeconds: - return "interval.five.seconds".localized case .tenSeconds: return "interval.ten.seconds".localized case .fifteenSeconds: diff --git a/Meshtastic/Extensions/CLLocationCoordinate2D.swift b/Meshtastic/Extensions/CLLocationCoordinate2D.swift index 32a47774..58287add 100644 --- a/Meshtastic/Extensions/CLLocationCoordinate2D.swift +++ b/Meshtastic/Extensions/CLLocationCoordinate2D.swift @@ -18,3 +18,45 @@ extension CLLocationCoordinate2D { return from.distance(from: to) } } + +extension [CLLocationCoordinate2D] { + /// Get Convex Hull For an array of CLLocationCoordinate2D positions + /// - Returns: A smaller CLLocationCoordinate2D array containing only the points necessary to create a convex hull polygon + func getConvexHull() -> [CLLocationCoordinate2D] { + /// X = longitude + /// Y = latitude + /// 2D cross product of OA and OB vectors, i.e. z-component of their 3D cross product. + /// Returns a positive value, if OAB makes a counter-clockwise turn, + /// negative for clockwise turn, and zero if the points are collinear. + func cross(P: CLLocationCoordinate2D, A: CLLocationCoordinate2D, B: CLLocationCoordinate2D) -> Double { + let part1 = (A.longitude - P.longitude) * (B.latitude - P.latitude) + let part2 = (A.latitude - P.latitude) * (B.longitude - P.longitude) + return part1 - part2; + } + // Sort points lexicographically + let points = self.sorted() { + $0.longitude == $1.longitude ? $0.latitude < $1.latitude : $0.longitude < $1.longitude + } + // Build the lower hull + var lower: [CLLocationCoordinate2D] = [] + for p in points { + while lower.count >= 2 && cross(P: lower[lower.count - 2], A: lower[lower.count - 1], B: p) <= 0 { + lower.removeLast() + } + lower.append(p) + } + // Build upper hull + var upper: [CLLocationCoordinate2D] = [] + for p in points.reversed() { + while upper.count >= 2 && cross(P: upper[upper.count-2], A: upper[upper.count-1], B: p) <= 0 { + upper.removeLast() + } + upper.append(p) + } + // Last point of upper list is omitted because it is repeated at the + // beginning of the lower list. + upper.removeLast() + // Concatenation of the lower and upper hulls gives the convex hull. + return (upper + lower) + } +} diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index d453094e..48305399 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -18,6 +18,7 @@ extension UserDefaults { case meshMapRecentering case meshMapShowNodeHistory case meshMapShowRouteLines + case enableMapConvexHull case enableMapTraffic case enableMapPointsOfInterest case enableOfflineMaps @@ -98,6 +99,14 @@ extension UserDefaults { UserDefaults.standard.set(newValue, forKey: "meshMapShowRouteLines") } } + static var enableMapConvexHull: Bool { + get { + UserDefaults.standard.bool(forKey: "enableMapConvexHull") + } + set { + UserDefaults.standard.set(newValue, forKey: "enableMapConvexHull") + } + } static var enableMapTraffic: Bool { get { UserDefaults.standard.bool(forKey: "enableMapTraffic") diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index ab010d52..6d99e61a 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -30,6 +30,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate @Published var mqttProxyConnected: Bool = false @StateObject var appState = AppState.shared + //public var locationHelper = LocationHelper.shared public var minimumVersion = "2.0.0" public var connectedVersion: String public var isConnecting: Bool = false @@ -42,6 +43,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate var lastPosition: CLLocationCoordinate2D? let emptyNodeNum: UInt32 = 4294967295 let mqttManager = MqttClientProxyManager.shared + //var locationHelper = LocationHelper.shared var wantRangeTestPackets = false /* Meshtastic Service Details */ var TORADIO_characteristic: CBCharacteristic! @@ -69,7 +71,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Scan for nearby BLE devices using the Meshtastic BLE service ID func startScanning() { if isSwitchedOn { - centralManager.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + centralManager.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) print("βœ… Scanning Started") } } @@ -486,18 +488,14 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } // Config if decodedInfo.config.isInitialized && !invalidVersion && connectedPeripheral != nil { - nowKnown = true localConfig(config: decodedInfo.config, context: context!, nodeNum: self.connectedPeripheral.num, nodeLongName: self.connectedPeripheral.longName) } // Module Config if decodedInfo.moduleConfig.isInitialized && !invalidVersion { - nowKnown = true moduleConfig(config: decodedInfo.moduleConfig, context: context!, nodeNum: self.connectedPeripheral.num, nodeLongName: self.connectedPeripheral.longName) - if decodedInfo.moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(decodedInfo.moduleConfig.cannedMessage) { - if decodedInfo.moduleConfig.cannedMessage.enabled { _ = self.getCannedMessageModuleMessages(destNum: self.connectedPeripheral.num, wantResponse: true) } @@ -508,9 +506,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate nowKnown = true deviceMetadataPacket(metadata: decodedInfo.metadata, fromNum: connectedPeripheral.num, context: context!) connectedPeripheral.firmwareVersion = decodedInfo.metadata.firmwareVersion - let lastDotIndex = decodedInfo.metadata.firmwareVersion.lastIndex(of: ".") - if lastDotIndex == nil { invalidVersion = true connectedVersion = "0.0.0" @@ -520,19 +516,15 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate connectedVersion = String(version.dropLast()) appState.firmwareVersion = connectedVersion } - let supportedVersion = connectedVersion == "0.0.0" || self.minimumVersion.compare(connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(connectedVersion, options: .numeric) == .orderedSame - if !supportedVersion { invalidVersion = true lastConnectionError = "🚨" + "update.firmware".localized return - } } // 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, blockRangeTest: UserDefaults.blockRangeTest, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) case .remoteHardwareApp: @@ -844,24 +836,12 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return success } - public func sendPosition(destNum: Int64, wantResponse: Bool, smartPosition: Bool) -> Bool { + public func sendPosition(destNum: Int64, wantResponse: Bool) -> Bool { var success = false let fromNodeNum = connectedPeripheral.num if fromNodeNum <= 0 || LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) == 0.0 { return false } - - if smartPosition { - if lastPosition != nil { - let connectedNode = getNodeInfo(id: connectedPeripheral?.num ?? 0, context: context!) - if connectedNode?.positionConfig?.smartPositionEnabled ?? false { - if lastPosition!.distance(from: LocationHelper.currentLocation) < Double(connectedNode?.positionConfig?.broadcastSmartMinimumDistance ?? 50) { - return false - } - } - } - } - lastPosition = LocationHelper.currentLocation var positionPacket = Position() positionPacket.latitudeI = Int32(LocationHelper.currentLocation.latitude * 1e7) positionPacket.longitudeI = Int32(LocationHelper.currentLocation.longitude * 1e7) @@ -893,6 +873,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true let logString = String.localizedStringWithFormat("mesh.log.sharelocation %@".localized, String(fromNodeNum)) + print(positionPacket) MeshLogger.log("πŸ“ \(logString)") } return success @@ -902,7 +883,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if connectedPeripheral != nil { // Send a position out to the mesh if "share location with the mesh" is enabled in settings if UserDefaults.provideLocation { - let _ = sendPosition(destNum: connectedPeripheral.num, wantResponse: false, smartPosition: true) + let _ = sendPosition(destNum: connectedPeripheral.num, wantResponse: false) } } } diff --git a/Meshtastic/Helpers/LocationHelper.swift b/Meshtastic/Helpers/LocationHelper.swift index 14e1af70..118623b8 100644 --- a/Meshtastic/Helpers/LocationHelper.swift +++ b/Meshtastic/Helpers/LocationHelper.swift @@ -1,9 +1,12 @@ import Foundation import CoreLocation +import MapKit class LocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate { static let shared = LocationHelper() var locationManager = CLLocationManager() + + //@Published var region = MKCoordinateRegion() @Published var authorizationStatus: CLAuthorizationStatus? override init() { super.init() @@ -89,6 +92,13 @@ class LocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate { } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { +// locationManager.stopUpdatingLocation() +// locations.last.map { +// region = MKCoordinateRegion( +// center: $0.coordinate, +// span: .init(latitudeDelta: 0.01, longitudeDelta: 0.01) +// ) +// } } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { print("Location manager error: \(error.localizedDescription)") diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 348d9e99..074f016d 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV18.xcdatamodel + MeshtasticDataModelV19.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV18.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV18.xcdatamodel/contents index c30e2f4b..ef4d9a8d 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV18.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV18.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -345,7 +345,7 @@ - + diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV19.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV19.xcdatamodel/contents new file mode 100644 index 00000000..0dc699d5 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV19.xcdatamodel/contents @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 17310c61..16d253a7 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -218,7 +218,7 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) position.latest = false } } - + print("Incoming position message: \n \(positionMessage)") let position = PositionEntity(context: context) position.latest = true position.snr = packet.rxSnr diff --git a/Meshtastic/Protobufs/meshtastic/config.pb.swift b/Meshtastic/Protobufs/meshtastic/config.pb.swift index eeee9214..6cb98a73 100644 --- a/Meshtastic/Protobufs/meshtastic/config.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/config.pb.swift @@ -186,6 +186,10 @@ struct Config { /// Clients should then limit available configuration and administrative options inside the user interface var isManaged: Bool = false + /// + /// Disables the triple-press of user button to enable or disable GPS + var disableTripleClick: Bool = false + var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -371,6 +375,10 @@ struct Config { /// The minimum number of seconds (since the last send) before we can send a position to the mesh if position_broadcast_smart_enabled var broadcastSmartMinimumIntervalSecs: UInt32 = 0 + /// + /// (Re)define PIN_GPS_EN for your board. + var gpsEnGpio: UInt32 = 0 + var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -951,6 +959,7 @@ struct Config { /// /// Maximum number of hops. This can't be greater than 7. /// Default of 3 + /// Attempting to set a value > 7 results in the default var hopLimit: UInt32 = 0 /// @@ -1596,6 +1605,7 @@ extension Config.DeviceConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl 7: .standard(proto: "node_info_broadcast_secs"), 8: .standard(proto: "double_tap_as_button_press"), 9: .standard(proto: "is_managed"), + 10: .standard(proto: "disable_triple_click"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -1613,6 +1623,7 @@ extension Config.DeviceConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl case 7: try { try decoder.decodeSingularUInt32Field(value: &self.nodeInfoBroadcastSecs) }() case 8: try { try decoder.decodeSingularBoolField(value: &self.doubleTapAsButtonPress) }() case 9: try { try decoder.decodeSingularBoolField(value: &self.isManaged) }() + case 10: try { try decoder.decodeSingularBoolField(value: &self.disableTripleClick) }() default: break } } @@ -1646,6 +1657,9 @@ extension Config.DeviceConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl if self.isManaged != false { try visitor.visitSingularBoolField(value: self.isManaged, fieldNumber: 9) } + if self.disableTripleClick != false { + try visitor.visitSingularBoolField(value: self.disableTripleClick, fieldNumber: 10) + } try unknownFields.traverse(visitor: &visitor) } @@ -1659,6 +1673,7 @@ extension Config.DeviceConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl if lhs.nodeInfoBroadcastSecs != rhs.nodeInfoBroadcastSecs {return false} if lhs.doubleTapAsButtonPress != rhs.doubleTapAsButtonPress {return false} if lhs.isManaged != rhs.isManaged {return false} + if lhs.disableTripleClick != rhs.disableTripleClick {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -1698,6 +1713,7 @@ extension Config.PositionConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageIm 9: .standard(proto: "tx_gpio"), 10: .standard(proto: "broadcast_smart_minimum_distance"), 11: .standard(proto: "broadcast_smart_minimum_interval_secs"), + 12: .standard(proto: "gps_en_gpio"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -1717,6 +1733,7 @@ extension Config.PositionConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageIm case 9: try { try decoder.decodeSingularUInt32Field(value: &self.txGpio) }() case 10: try { try decoder.decodeSingularUInt32Field(value: &self.broadcastSmartMinimumDistance) }() case 11: try { try decoder.decodeSingularUInt32Field(value: &self.broadcastSmartMinimumIntervalSecs) }() + case 12: try { try decoder.decodeSingularUInt32Field(value: &self.gpsEnGpio) }() default: break } } @@ -1756,6 +1773,9 @@ extension Config.PositionConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageIm if self.broadcastSmartMinimumIntervalSecs != 0 { try visitor.visitSingularUInt32Field(value: self.broadcastSmartMinimumIntervalSecs, fieldNumber: 11) } + if self.gpsEnGpio != 0 { + try visitor.visitSingularUInt32Field(value: self.gpsEnGpio, fieldNumber: 12) + } try unknownFields.traverse(visitor: &visitor) } @@ -1771,6 +1791,7 @@ extension Config.PositionConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageIm if lhs.txGpio != rhs.txGpio {return false} if lhs.broadcastSmartMinimumDistance != rhs.broadcastSmartMinimumDistance {return false} if lhs.broadcastSmartMinimumIntervalSecs != rhs.broadcastSmartMinimumIntervalSecs {return false} + if lhs.gpsEnGpio != rhs.gpsEnGpio {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Meshtastic/Tips/BluetoothTips.swift b/Meshtastic/Tips/BluetoothTips.swift index 36fe7583..f7540b66 100644 --- a/Meshtastic/Tips/BluetoothTips.swift +++ b/Meshtastic/Tips/BluetoothTips.swift @@ -22,6 +22,6 @@ struct BluetoothConnectionTip: Tip { Text("tip.bluetooth.connect.message") } var image: Image? { - Image(systemName: "questionmark.circle") + Image(systemName: "flipphone") } } diff --git a/Meshtastic/Tips/ChannelTips.swift b/Meshtastic/Tips/ChannelTips.swift index b98e4032..cdd5d9c7 100644 --- a/Meshtastic/Tips/ChannelTips.swift +++ b/Meshtastic/Tips/ChannelTips.swift @@ -22,6 +22,6 @@ Text("tip.channels.share.message") } var image: Image? { - Image(systemName: "questionmark.circle") + Image(systemName: "qrcode") } } diff --git a/Meshtastic/Tips/MessagesTips.swift b/Meshtastic/Tips/MessagesTips.swift index ce655583..b2bdf295 100644 --- a/Meshtastic/Tips/MessagesTips.swift +++ b/Meshtastic/Tips/MessagesTips.swift @@ -22,6 +22,25 @@ struct MessagesTip: Tip { Text("tip.messages.message") } var image: Image? { - Image(systemName: "questionmark.circle") + Image(systemName: "bubble.left.and.bubble.right") + } +} + +@available(iOS 17.0, macOS 14.0, *) +struct ContactsTip: Tip { + + var id: String { + return "tip.messages.contacts" + } + var title: Text { + //Text("tip.messages.contacts.title") + Text("Contacts") + } + var message: Text? { + //Text("tip.messages.contacts.message") + Text("Each node shows as an available contact. Nodes with recent messages and favorites show up at the top of the list. Select a node to send or view messages. Long press to favorite or mute the node, send a trace route or delete the conversation.") + } + var image: Image? { + Image(systemName: "person.circle") } } diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 5df1a487..8349abf3 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -123,6 +123,11 @@ struct ChannelList: View { Text("delete") } } + .onAppear { + if self.bleManager.context == nil { + self.bleManager.context = context + } + } } } .padding([.top, .bottom]) diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 57b27edd..74a92b70 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -259,7 +259,9 @@ struct ChannelMessageList: View { .padding([.top]) .scrollDismissesKeyboard(.immediately) .onAppear(perform: { - self.bleManager.context = context + if self.bleManager.context == nil { + self.bleManager.context = context + } if channel.allPrivateMessages.count > 0 { scrollView.scrollTo(channel.allPrivateMessages.last!.messageId) } @@ -384,7 +386,7 @@ struct ChannelMessageList: View { focusedField = nil replyMessageId = 0 if sendPositionWithMessage { - if bleManager.sendPosition(destNum: Int64(channel.index), wantResponse: false, smartPosition: false) { + if bleManager.sendPosition(destNum: Int64(channel.index), wantResponse: false) { print("Location Sent") } } @@ -401,7 +403,7 @@ struct ChannelMessageList: View { focusedField = nil replyMessageId = 0 if sendPositionWithMessage { - if bleManager.sendPosition(destNum: Int64(channel.index), wantResponse: false, smartPosition: false) { + if bleManager.sendPosition(destNum: Int64(channel.index), wantResponse: false) { print("Location Sent") } } @@ -409,6 +411,7 @@ struct ChannelMessageList: View { }) { Image(systemName: "arrow.up.circle.fill").font(.largeTitle).foregroundColor(.accentColor) } + } .padding(.all, 15) } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index c4bd1f89..f22414cd 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -63,7 +63,9 @@ struct Messages: View { .navigationBarTitleDisplayMode(.large) .navigationBarItems(leading: MeshtasticLogo()) .onAppear { - self.bleManager.context = context + if self.bleManager.context == nil { + self.bleManager.context = context + } if UserDefaults.preferredPeripheralId.count > 0 { let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1)) diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 4cf0f135..8959407e 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -7,6 +7,9 @@ import SwiftUI import CoreData +#if canImport(TipKit) +import TipKit +#endif struct UserList: View { @@ -37,6 +40,9 @@ struct UserList: View { let dateFormatString = (localeDateFormat ?? "MM/dd/YY") VStack { List { + if #available(iOS 17.0, macOS 14.0, *) { + TipView(ContactsTip(), arrowEdge: .bottom) + } ForEach(users) { (user: UserEntity) in let mostRecent = user.messageList.last let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) @@ -96,60 +102,60 @@ struct UserList: View { } } } - .frame(height: 62) - .contextMenu { - Button { - user.vip = !user.vip - do { - try context.save() - } catch { - context.rollback() - print("πŸ’₯ Save User VIP Error") - } - } label: { - Label(user.vip ? "Un-Favorite" : "Favorite", systemImage: user.vip ? "star.slash.fill" : "star.fill") + .frame(height: 62) + .contextMenu { + Button { + user.vip = !user.vip + do { + try context.save() + } catch { + context.rollback() + print("πŸ’₯ Save User VIP Error") } - Button { - user.mute = !user.mute - do { - try context.save() - } catch { - context.rollback() - print("πŸ’₯ Save User Mute Error") - } - } label: { - Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash") + } label: { + Label(user.vip ? "Un-Favorite" : "Favorite", systemImage: user.vip ? "star.slash.fill" : "star.fill") + } + Button { + user.mute = !user.mute + do { + try context.save() + } catch { + context.rollback() + print("πŸ’₯ Save User Mute Error") } - Button { - let success = bleManager.sendTraceRouteRequest(destNum: user.num, wantResponse: true) - if success { - isPresentingTraceRouteSentAlert = true - } - } label: { - Label("Trace Route", systemImage: "signpost.right.and.left") + } label: { + Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash") + } + Button { + let success = bleManager.sendTraceRouteRequest(destNum: user.num, wantResponse: true) + if success { + isPresentingTraceRouteSentAlert = true } - if user.messageList.count > 0 { - Button(role: .destructive) { - isPresentingDeleteUserMessagesConfirm = true - userSelection = user - } label: { - Label("Delete Messages", systemImage: "trash") - } + } label: { + Label("Trace Route", systemImage: "signpost.right.and.left") + } + if user.messageList.count > 0 { + Button(role: .destructive) { + isPresentingDeleteUserMessagesConfirm = true + userSelection = user + } label: { + Label("Delete Messages", systemImage: "trash") } } - .alert( - "Trace Route Sent", - isPresented: $isPresentingTraceRouteSentAlert - ) { - Button("OK", role: .cancel) { } - } message: { - Text("This could take a while, response will appear in the mesh log.") - } - .confirmationDialog( - "This conversation will be deleted.", - isPresented: $isPresentingDeleteUserMessagesConfirm, - titleVisibility: .visible - ) { + } + .alert( + "Trace Route Sent", + isPresented: $isPresentingTraceRouteSentAlert + ) { + Button("OK", role: .cancel) { } + } message: { + Text("This could take a while, response will appear in the mesh log.") + } + .confirmationDialog( + "This conversation will be deleted.", + isPresented: $isPresentingDeleteUserMessagesConfirm, + titleVisibility: .visible + ) { Button(role: .destructive) { deleteUserMessages(user: userSelection!, context: context) context.refresh(node!.user!, mergeChanges: true) diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 12d28783..c2458d71 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -237,12 +237,14 @@ struct UserMessageList: View { } .padding([.top]) .scrollDismissesKeyboard(.immediately) - .onAppear(perform: { - self.bleManager.context = context + .onAppear { + if self.bleManager.context == nil { + self.bleManager.context = context + } if user.messageList.count > 0 { scrollView.scrollTo(user.messageList.last!.messageId) } - }) + } .onChange(of: user.messageList, perform: { _ in if user.messageList.count > 0 { scrollView.scrollTo(user.messageList.last!.messageId) @@ -335,7 +337,7 @@ struct UserMessageList: View { focusedField = nil replyMessageId = 0 if sendPositionWithMessage { - if bleManager.sendPosition(destNum: user.num, wantResponse: true, smartPosition: false) { + if bleManager.sendPosition(destNum: user.num, wantResponse: true) { print("Location Sent") } } @@ -352,7 +354,7 @@ struct UserMessageList: View { focusedField = nil replyMessageId = 0 if sendPositionWithMessage { - if bleManager.sendPosition(destNum: user.num, wantResponse: true, smartPosition: false) { + if bleManager.sendPosition(destNum: user.num, wantResponse: true) { print("Location Sent") } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 4c889031..f60337cb 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -42,7 +42,7 @@ struct NodeDetail: View { Divider() NavigationLink { if #available (iOS 17, macOS 14, *) { - NodeMapSwiftUI(node: node) + NodeMapSwiftUI(node: node, showUserLocation: connectedNode?.num ?? 0 == node.num) } else { NodeMapControl(node: node) } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index f2397f26..7f4afcdb 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -30,9 +30,16 @@ struct NodeListItem: View { } } VStack(alignment: .leading) { - Text(node.user?.longName ?? "unknown".localized) - .fontWeight(.medium) - .font(.callout) + HStack { + Text(node.user?.longName ?? "unknown".localized) + .fontWeight(.medium) + .font(.callout) + if node.user?.vip ?? false { + Spacer() + Image(systemName: "star.fill") + .foregroundColor(.secondary) + } + } if connected { HStack { Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") diff --git a/Meshtastic/Views/Nodes/Helpers/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/NodeMapSwiftUI.swift index 26c45a0a..aaa8732c 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeMapSwiftUI.swift @@ -14,30 +14,41 @@ import WeatherKit struct NodeMapSwiftUI: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - /// Map State - @Namespace var mapScope + /// Parameters + @ObservedObject var node: NodeInfoEntity + @State var showUserLocation: Bool = false + @State var positions: [PositionEntity] = [] + //@State var waypoints: [WaypointEntity] = [] + /// Map State User Defaults @AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false @AppStorage("meshMapShowRouteLines") private var showRouteLines = false + @AppStorage("meshMapShowConvexHull") private var showConvexHull = true @AppStorage("enableMapTraffic") private var showTraffic: Bool = true @AppStorage("enableMapPointsOfInterest") private var showPointsOfInterest: Bool = true @AppStorage("mapLayer") private var selectedMapLayer: MapLayer = .hybrid + // Map Configuration + @Namespace var mapScope @State private var mapStyle: MapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: .all, showsTraffic: true) @State private var position = MapCameraPosition.automatic @State private var scene: MKLookAroundScene? @State private var isLookingAround = false @State private var isEditingSettings = false - @State private var showUserLocation: Bool = false - @State var selected: PositionEntity? - /// Data - @ObservedObject var node: NodeInfoEntity + @State private var selected: PositionEntity? + @State private var selectedWaypoint: WaypointEntity? + @State private var selectedWaypointRect: CGRect = .zero + @State private var selectedWaypointPoint: CGPoint = .zero + @State private var showingPositionPopover = false + @State private var showingWaypointPopover = false + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: false)], predicate: NSPredicate( format: "expire == nil || expire >= %@", Date() as NSDate ), animation: .none) private var waypoints: FetchedResults + @State var waypoiintSelectionRect: CGRect = .zero var body: some View { - let nodeColor = UIColor(hex: UInt32(node.num)) + let positionArray = node.positions?.array as? [PositionEntity] ?? [] let mostRecent = node.positions?.lastObject as? PositionEntity let lineCoords = positionArray.compactMap({(position) -> CLLocationCoordinate2D in @@ -46,165 +57,226 @@ struct NodeMapSwiftUI: View { if node.hasPositions { ZStack { - Map(position: $position, bounds: MapCameraBounds(minimumDistance: 1, maximumDistance: .infinity), scope: mapScope) { - /// Route Lines - if showRouteLines { - let gradient = LinearGradient( - colors: [Color(nodeColor.lighter().lighter().lighter()), Color(nodeColor.lighter()), Color(nodeColor)], - startPoint: .leading, endPoint: .trailing - ) - let stroke = StrokeStyle( - lineWidth: 5, - lineCap: .round, lineJoin: .round, dash: [10, 10] - ) - MapPolyline(coordinates: lineCoords) - .stroke(gradient, style: stroke) - } - /// Node Annotations - ForEach(positionArray.reversed(), id: \.id) { position in - let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 3)) - let formatter = MeasurementFormatter() - let speedText = formatter.string(from: Measurement(value: Double(position.speed), unit: UnitSpeed.kilometersPerHour)) - Annotation(position.latest ? node.user?.shortName ?? "?" : (pf.contains(.Speed) && position.speed > 2) ? speedText : "", coordinate: position.coordinate) { - ZStack { - if position.latest { - Circle() - .foregroundStyle(Color(nodeColor.lighter()).opacity(0.4)) - .frame(width: 60, height: 60) - if pf.contains(.Heading) { - Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north.fill" : "location.north") - .symbolEffect(.pulse.byLayer) - .padding(5) - .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) - .background(Color(UIColor(hex: UInt32(node.num)).darker())) - .clipShape(Circle()) - .rotationEffect(.degrees(Double(position.heading))) -// .onTapGesture { -// selected = (selected == position ? nil : position) // <-- here -// print("tapity tap tap \(position.time)") -// } - } else { - Image(systemName: "flipphone") - .symbolEffect(.pulse.byLayer) - .padding(5) - .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) - .background(Color(UIColor(hex: UInt32(node.num)).darker())) - .clipShape(Circle()) -// .onTapGesture { -// selected = (selected == position ? nil : position) // <-- here -// print("tapity tap tap \(position.time)") -// } - } - } else { - if showNodeHistory { + MapReader { reader in + Map(position: $position, bounds: MapCameraBounds(minimumDistance: 1, maximumDistance: .infinity), scope: mapScope) { + /// Node Color from node.num + let nodeColor = UIColor(hex: UInt32(node.num)) + /// Route Lines + if showRouteLines { + if showRouteLines { + let gradient = LinearGradient( + colors: [Color(nodeColor.lighter().lighter().lighter()), Color(nodeColor.lighter()), Color(nodeColor)], + startPoint: .leading, endPoint: .trailing + ) + let dashed = StrokeStyle( + lineWidth: 5, + lineCap: .round, lineJoin: .round, dash: [10, 10] + ) + MapPolyline(coordinates: lineCoords) + .stroke(gradient, style: dashed) + } + } + /// Convex Hull + if showConvexHull { + let hull = lineCoords.getConvexHull() + MapPolygon(coordinates: hull) + .stroke(Color(nodeColor.darker()), lineWidth: 5) + .foregroundStyle(Color(nodeColor).opacity(0.4)) + } + /// Waypoint Annotations + ForEach(Array(waypoints), id: \.id) { waypoint in + Annotation(waypoint.name ?? "?", coordinate: waypoint.coordinate) { + ZStack { + CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“"), color: Color.orange, circleSize: 35) +// .onTapGesture(coordinateSpace: .global) { location in +// print("Tapped at \(location)") +// let pinLocation = reader.convert(location, from: .local) +// print(pinLocation) +// let size = CGSize(width: 1, height: 50) +// let rect = CGRect(origin: location, size: size) +// selectedWaypointRect = rect +// selectedWaypointPoint = location +// showingWaypointPopover = true +// selectedWaypoint = (selectedWaypoint == waypoint ? nil : waypoint) +// } + } + } + } + /// Node Annotations + ForEach(positionArray, id: \.id) { position in + let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 3)) + let formatter = MeasurementFormatter() + let headingDegrees = Angle.degrees(Double(position.heading)) + Annotation(position.latest ? node.user?.shortName ?? "?": "", coordinate: position.coordinate) { + ZStack { + if position.latest { + Circle() + .foregroundStyle(Color(nodeColor.lighter()).opacity(0.4)) + .frame(width: 60, height: 60) if pf.contains(.Heading) { - Image(systemName: pf.contains(.Speed) && position.speed > 0 ? "location.north.fill" : "hexagon") - .padding(2) - .foregroundStyle(Color(UIColor(hex: UInt32(node.num)).lighter()).isLight() ? .black : .white) - .background(Color(UIColor(hex: UInt32(node.num)).lighter())) + Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north" : "hexagon") + .symbolEffect(.pulse.byLayer) + .padding(5) + .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) + .background(Color(UIColor(hex: UInt32(node.num)).darker())) .clipShape(Circle()) - .rotationEffect(.degrees(Double(position.heading))) + .rotationEffect(headingDegrees) + .onTapGesture { + showingPositionPopover = true + selected = (selected == position ? nil : position) // <-- here + } + .popover(isPresented: $showingPositionPopover) { + PositionPopover(position: position) + .padding() + .opacity(0.8) + .presentationCompactAdaptation(.popover) + } } else { - Image(systemName: "mappin.circle") - .padding(2) - .foregroundStyle(Color(UIColor(hex: UInt32(node.num)).lighter()).isLight() ? .black : .white) - .background(Color(UIColor(hex: UInt32(node.num)).lighter())) + Image(systemName: "flipphone") + .symbolEffect(.pulse.byLayer) + .padding(5) + .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) + .background(Color(UIColor(hex: UInt32(node.num)).darker())) .clipShape(Circle()) + .onTapGesture { + showingPositionPopover = true + selected = (selected == position ? nil : position) // <-- here + } + .popover(isPresented: $showingPositionPopover, arrowEdge: .bottom) { + PositionPopover(position: position) + .tag(position.id) + .padding() + .opacity(0.8) + .presentationCompactAdaptation(.popover) + } + } + } else { + if showNodeHistory { + if pf.contains(.Heading) { + Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north.circle" : "hexagon") + .padding(2) + .foregroundStyle(Color(UIColor(hex: UInt32(node.num)).lighter()).isLight() ? .black : .white) + .background(Color(UIColor(hex: UInt32(node.num)).lighter())) + .clipShape(Circle()) + .rotationEffect(headingDegrees) + } else { + Image(systemName: "mappin.circle") + .padding(2) + .foregroundStyle(Color(UIColor(hex: UInt32(node.num)).lighter()).isLight() ? .black : .white) + .background(Color(UIColor(hex: UInt32(node.num)).lighter())) + .clipShape(Circle()) + } } } } } + .tag(position.time) + .annotationTitles(.automatic) + .annotationSubtitles(.automatic) } - .tag(position.time) } - } - .mapScope(mapScope) - .mapStyle(mapStyle) - .mapControls { - MapScaleView(scope: mapScope) - .mapControlVisibility(.visible) - if showUserLocation { - MapUserLocationButton(scope: mapScope) + .mapScope(mapScope) + .mapStyle(mapStyle) + .mapControls { + MapScaleView(scope: mapScope) + .mapControlVisibility(.visible) + if showUserLocation { + MapUserLocationButton(scope: mapScope) + .mapControlVisibility(.visible) + } + MapPitchToggle(scope: mapScope) + .mapControlVisibility(.visible) + MapCompass(scope: mapScope) .mapControlVisibility(.visible) } - MapPitchToggle(scope: mapScope) - .mapControlVisibility(.visible) - MapCompass(scope: mapScope) - .mapControlVisibility(.visible) - } - .controlSize(.regular) - .overlay(alignment: .bottom) { - if scene != nil && isLookingAround { - LookAroundPreview(initialScene: scene) - .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 20) + .controlSize(.regular) + .overlay(alignment: .bottom) { + if scene != nil && isLookingAround { + LookAroundPreview(initialScene: scene) + .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } } - } - .sheet(isPresented: $isEditingSettings) { - VStack { - Form { - Section(header: Text("Map Options")) { - Picker(selection: $selectedMapLayer, label: Text("")) { - ForEach(MapLayer.allCases, id: \.self) { layer in - if layer != MapLayer.offline { - Text(layer.localized) +// .popover(item: $selectedWaypoint, attachmentAnchor: .rect(.rect(selectedWaypointRect)), arrowEdge: .bottom) { selection in +// //.popover(isPresented: $showingWaypointPopover, arrowEdge: .bottom) { +// WaypointPopover(waypoint: selection) +// .padding() +// .opacity(0.8) +// .presentationCompactAdaptation(.popover) +// } + .sheet(isPresented: $isEditingSettings) { + VStack { + Form { + Section(header: Text("Map Options")) { + Picker(selection: $selectedMapLayer, label: Text("")) { + ForEach(MapLayer.allCases, id: \.self) { layer in + if layer != MapLayer.offline { + Text(layer.localized) + } } } - } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: (selectedMapLayer)) { newMapLayer in - switch selectedMapLayer { - case .standard: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.standard(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .hybrid: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .satellite: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.imagery(elevation: .realistic) - case .offline: - return + .pickerStyle(SegmentedPickerStyle()) + .onChange(of: (selectedMapLayer)) { newMapLayer in + switch selectedMapLayer { + case .standard: + UserDefaults.mapLayer = newMapLayer + mapStyle = MapStyle.standard(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .hybrid: + UserDefaults.mapLayer = newMapLayer + mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .satellite: + UserDefaults.mapLayer = newMapLayer + mapStyle = MapStyle.imagery(elevation: .realistic) + case .offline: + return + } + } + .padding(.top, 5) + .padding(.bottom, 5) + Toggle(isOn: $showNodeHistory) { + Label("Node History", systemImage: "building.columns.fill") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onTapGesture { + self.showNodeHistory.toggle() + UserDefaults.enableMapNodeHistoryPins = self.showNodeHistory + } + Toggle(isOn: $showRouteLines) { + Label("Route Lines", systemImage: "road.lanes") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onTapGesture { + self.showRouteLines.toggle() + UserDefaults.enableMapRouteLines = self.showRouteLines + } + Toggle(isOn: $showConvexHull) { + Label("Convex Hull", systemImage: "button.angledbottom.horizontal.right") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onTapGesture { + self.showConvexHull.toggle() + UserDefaults.enableMapConvexHull = self.showConvexHull + } + Toggle(isOn: $showTraffic) { + Label("Traffic", systemImage: "car") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onTapGesture { + self.showTraffic.toggle() + UserDefaults.enableMapTraffic = self.showTraffic + } + Toggle(isOn: $showPointsOfInterest) { + Label("Points of Interest", systemImage: "mappin.and.ellipse") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onTapGesture { + self.showPointsOfInterest.toggle() + UserDefaults.enableMapPointsOfInterest = self.showPointsOfInterest } } - .padding(.top, 5) - .padding(.bottom, 5) - Toggle(isOn: $showNodeHistory) { - Label("Node History", systemImage: "building.columns.fill") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .onTapGesture { - self.showNodeHistory.toggle() - UserDefaults.enableMapNodeHistoryPins = self.showNodeHistory - } - Toggle(isOn: $showRouteLines) { - Label("Route Lines", systemImage: "road.lanes") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .onTapGesture { - self.showRouteLines.toggle() - UserDefaults.enableMapRouteLines = self.showRouteLines - } - Toggle(isOn: $showTraffic) { - Label("Traffic", systemImage: "car") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .onTapGesture { - self.showTraffic.toggle() - UserDefaults.enableMapTraffic = self.showTraffic - } - Toggle(isOn: $showPointsOfInterest) { - Label("Points of Interest", systemImage: "mappin.and.ellipse") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .onTapGesture { - self.showPointsOfInterest.toggle() - UserDefaults.enableMapPointsOfInterest = self.showPointsOfInterest - } } - } - #if targetEnvironment(macCatalyst) +#if targetEnvironment(macCatalyst) Button { isEditingSettings = false } label: { @@ -214,84 +286,84 @@ struct NodeMapSwiftUI: View { .buttonBorderShape(.capsule) .controlSize(.large) .padding() - #endif - } - //.presentationDetents([.fraction(0.4)]) - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .onChange(of: node) { - let mostRecent = node.positions?.lastObject as? PositionEntity - position = .camera(MapCamera(centerCoordinate: mostRecent!.coordinate, distance: 1500, heading: 0, pitch: 0)) - if let mostRecent { - Task { - scene = try? await fetchScene(for: mostRecent.coordinate) +#endif } + .presentationDetents([.fraction(0.46)]) + //.presentationDetents([.medium]) + .presentationDragIndicator(.visible) } - } - .onAppear { - UIApplication.shared.isIdleTimerDisabled = true - switch selectedMapLayer { - case .standard: - mapStyle = MapStyle.standard(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .hybrid: - mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .satellite: - mapStyle = MapStyle.imagery(elevation: .realistic) - case .offline: - mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - } - if self.scene == nil { - Task { - scene = try? await fetchScene(for: mostRecent!.coordinate) - } - } - } - .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { - HStack { - Button(action: { - withAnimation { - isEditingSettings = !isEditingSettings + .onChange(of: node) { + let mostRecent = node.positions?.lastObject as? PositionEntity + position = MapCameraPosition.automatic//.camera(MapCamera(centerCoordinate: mostRecent!.coordinate, distance: 1500, heading: 0, pitch: 0)) + if let mostRecent { + Task { + scene = try? await fetchScene(for: mostRecent.coordinate) } - }) { - Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - /// Look Around Button - if self.scene != nil { + } + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + switch selectedMapLayer { + case .standard: + mapStyle = MapStyle.standard(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .hybrid: + mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .satellite: + mapStyle = MapStyle.imagery(elevation: .realistic) + case .offline: + mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + } + if self.scene == nil { + Task { + scene = try? await fetchScene(for: mostRecent!.coordinate) + } + } + } + .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { + HStack { Button(action: { withAnimation { - isLookingAround = !isLookingAround + isEditingSettings = !isEditingSettings } }) { - Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") + Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") .padding(.vertical, 5) } .tint(Color(UIColor.secondarySystemBackground)) .foregroundColor(.accentColor) .buttonStyle(.borderedProminent) - } - - #if targetEnvironment(macCatalyst) + /// Look Around Button + if self.scene != nil { + Button(action: { + withAnimation { + isLookingAround = !isLookingAround + } + }) { + Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + +#if targetEnvironment(macCatalyst) MapZoomStepper(scope: mapScope) .mapControlVisibility(.visible) MapPitchSlider(scope: mapScope) .mapControlVisibility(.visible) - #endif +#endif + } + .controlSize(.regular) + .padding(5) } - .controlSize(.regular) - .padding(5) - } - .onDisappear { - UIApplication.shared.isIdleTimerDisabled = false - } - } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + }} .navigationBarTitle(String((node.user?.shortName ?? "unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline) .navigationBarItems(trailing: - ZStack { + ZStack { ConnectedDevice( bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, @@ -303,7 +375,7 @@ struct NodeMapSwiftUI: View { } private func fetchScene(for coordinate: CLLocationCoordinate2D) async throws -> MKLookAroundScene? { - let lookAroundScene = MKLookAroundSceneRequest(coordinate: coordinate) - return try await lookAroundScene.scene + let lookAroundScene = MKLookAroundSceneRequest(coordinate: coordinate) + return try await lookAroundScene.scene } } diff --git a/Meshtastic/Views/Nodes/Helpers/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/PositionPopover.swift new file mode 100644 index 00000000..acac8ddf --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/PositionPopover.swift @@ -0,0 +1,126 @@ +// +// PositionPopover.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 9/17/23. +// + +import SwiftUI +import MapKit + +struct PositionPopover: View { + var position: PositionEntity + let distanceFormatter = MKDistanceFormatter() + var body: some View { + VStack { + HStack { + CircleText(text: position.nodePosition?.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(position.nodePosition?.user?.num ?? 0)))) + Text(position.nodePosition?.user?.longName ?? "Unknown") + .font(.title3) + let degrees = Angle.degrees(Double(position.heading)) + } + Divider() + VStack (alignment: .leading) { + /// Time + Label { + Text(position.time?.formatted() ?? "Unknown") + .foregroundColor(.primary) + } icon: { + Image(systemName: "clock.badge.checkmark") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + /// Coordinate + Label { + Text("\(String(format: "%.6f", position.coordinate.latitude)), \(String(format: "%.6f", position.coordinate.longitude))") + .font(.footnote) + .textSelection(.enabled) + .foregroundColor(.primary) + } icon: { + Image(systemName: "mappin.and.ellipse") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + /// Altitude + Label { + Text("Altitude: \(distanceFormatter.string(fromDistance: Double(position.altitude)))") + .foregroundColor(.primary) + } icon: { + Image(systemName: "mountain.2.fill") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 3)) + /// Sats in view + if pf.contains(.Satsinview) { + Label { + Text("Sats in view: \(String(position.satsInView))") + .foregroundColor(.primary) + } icon: { + Image(systemName: "sparkles") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Sequence Number + if pf.contains(.SeqNo) { + Label { + Text("Sequence: \(String(position.seqNo))") + .foregroundColor(.primary) + } icon: { + Image(systemName: "number") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Heading + if pf.contains(.Heading) { + let degrees = Angle.degrees(Double(position.heading)) + Label { + let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees) + Text("Heading: \(heading.formatted())") + .foregroundColor(.primary) + } icon: { + Image(systemName: "location.north") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + .rotationEffect(degrees) + } + .padding(.bottom, 5) + } + /// Speed + if pf.contains(.Speed) { + let formatter = MeasurementFormatter() + Label { + Text("Speed: \(formatter.string(from: Measurement(value: Double(position.speed), unit: UnitSpeed.kilometersPerHour)))") + // .font(.footnote) + .foregroundColor(.primary) + } icon: { + Image(systemName: "gauge.with.dots.needle.33percent") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Distance + if LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) > 0.0 { + let metersAway = position.coordinate.distance(from: LocationHelper.currentLocation) + Label { + Text("distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))") + // .font(.footnote) + .foregroundColor(.primary) + } icon: { + Image(systemName: "lines.measurement.horizontal") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + } + } + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/WaypointPopover.swift b/Meshtastic/Views/Nodes/Helpers/WaypointPopover.swift new file mode 100644 index 00000000..1c4d8f8d --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/WaypointPopover.swift @@ -0,0 +1,98 @@ +// +// WaypointPopover.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen on 9/19/23. +// + +import SwiftUI +import MapKit + +struct WaypointPopover: View { + var waypoint: WaypointEntity + let distanceFormatter = MKDistanceFormatter() + var body: some View { + VStack { + HStack { + CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“"), color: Color.blue) + Text(waypoint.name ?? "?") + .font(.title3) + if waypoint.locked > 0 { + Image(systemName: "lock.fill" ) + .font(.title2) + } else { + // Edit Button + } + } + Divider() + VStack (alignment: .leading) { + // Description + if (waypoint.longDescription ?? "").count > 0 { + Label { + Text(waypoint.longDescription ?? "") + .foregroundColor(.primary) + .font(.footnote) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "doc.plaintext") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Created + Label { + Text("Created: \(waypoint.created?.formatted() ?? "?")") + .foregroundColor(.primary) + .font(.footnote) + } icon: { + Image(systemName: "clock.badge.checkmark") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + /// Updated + if waypoint.lastUpdated != nil { + Label { + Text("Updated: \(waypoint.lastUpdated?.formatted() ?? "?")") + .foregroundColor(.primary) + .font(.footnote) + } icon: { + Image(systemName: "clock.arrow.circlepath") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Updated + if waypoint.expire != nil { + Label { + Text("Expires: \(waypoint.expire?.formatted() ?? "?")") + .foregroundColor(.primary) + .font(.footnote) + } icon: { + Image(systemName: "clock.badge.xmark") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Distance + if LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) > 0.0 { + let metersAway = waypoint.coordinate.distance(from: LocationHelper.currentLocation) + Label { + Text("distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))") + .foregroundColor(.primary) + .font(.footnote) + } icon: { + Image(systemName: "lines.measurement.horizontal") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + } + } + } + .tag(waypoint.id) + } +} diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 7a36fe44..7e6f30ec 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -31,7 +31,7 @@ struct NodeList: View { sortDescriptors: [NSSortDescriptor(key: "user.vip", ascending: false), NSSortDescriptor(key: "lastHeard", ascending: false)], animation: .default) - private var nodes: FetchedResults + var nodes: FetchedResults @@ -42,7 +42,39 @@ struct NodeList: View { let connectedNode = nodes.first(where: { $0.num == connectedNodeNum }) List(nodes, id: \.self, selection: $selectedNode) { node in - NodeListItem(node: node, connected: bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral?.num ?? -1 == node.num, connectedNode: (bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral?.num ?? -1 : -1), modemPreset: Int(connectedNode?.loRaConfig?.modemPreset ?? 0)) + NodeListItem(node: node, + connected: bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral?.num ?? -1 == node.num, + connectedNode: (bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral?.num ?? -1 : -1), + modemPreset: Int(connectedNode?.loRaConfig?.modemPreset ?? 0)) + .contextMenu { + if node.user != nil { + Button { + node.user!.vip = !node.user!.vip + context.refresh(node, mergeChanges: true) + do { + try context.save() + } catch { + context.rollback() + print("πŸ’₯ Save User VIP Error") + } + } label: { + Label(node.user?.vip ?? false ? "Un-Favorite" : "Favorite", systemImage: node.user?.vip ?? false ? "star.slash.fill" : "star.fill") + } + Button { + node.user!.mute = !node.user!.mute + context.refresh(node, mergeChanges: true) + do { + try context.save() + } catch { + context.rollback() + print("πŸ’₯ Save User Mute Error") + } + } label: { + Label(node.user!.mute ? "Show Alerts" : "Hide Alerts", systemImage: node.user!.mute ? "bell" : "bell.slash") + } + } + + } } .searchable(text: nodesQuery, prompt: "Find a node") .navigationTitle(String.localizedStringWithFormat("nodes %@".localized, String(nodes.count))) diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 0c8c034a..d8d0e5e0 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -50,7 +50,8 @@ struct PositionLog: View { Text(speed.formatted()) } TableColumn("Heading") { position in - Text("\(position.heading)Β°") + let heading = Measurement(value: Double(position.heading), unit: UnitAngle.degrees) + Text("\(heading.formatted())") } TableColumn("SNR") { position in Text("\(String(format: "%.2f", position.snr)) dB") diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 2ed55fe2..8ad8397c 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -277,7 +277,7 @@ struct PositionConfig: View { Button(buttonText) { if fixedPosition { - _ = bleManager.sendPosition(destNum: node!.num, wantResponse: true, smartPosition: false) + _ = bleManager.sendPosition(destNum: node!.num, wantResponse: true) } let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index ebc6e98e..e1b45f14 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -51,16 +51,16 @@ struct ShareChannels: View { var qrCodeImage = QrCodeImage() var body: some View { + + if #available(iOS 17.0, macOS 14.0, *) { + VStack { + TipView(ShareChannelsTip(), arrowEdge: .bottom) + } + } GeometryReader { bounds in let smallest = min(bounds.size.width, bounds.size.height) ScrollView { if node != nil && node?.myInfo != nil { - - if #available(iOS 17.0, macOS 14.0, *) { - VStack { - TipView(ShareChannelsTip(), arrowEdge: .top) - } - } Grid { GridRow { Spacer() diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 95230354..80d1fb50 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -9,7 +9,6 @@ import ActivityKit import WidgetKit import SwiftUI -@available(iOS 16.2, *) struct WidgetsLiveActivity: Widget { var body: some WidgetConfiguration { @@ -52,7 +51,7 @@ struct WidgetsLiveActivity: Widget { .foregroundColor(.gray) .fixedSize() } else { - Text("Plugged In") + Text("PWD") .font(.title3) .foregroundColor(.gray) } @@ -101,7 +100,6 @@ struct WidgetsLiveActivity: Widget { } } -@available(iOS 16.2, *) struct WidgetsLiveActivity_Previews: PreviewProvider { static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") static let state = MeshActivityAttributes.ContentState( @@ -123,7 +121,6 @@ struct WidgetsLiveActivity_Previews: PreviewProvider { } } -@available(iOS 16.2, *) struct LiveActivityView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.isLuminanceReduced) var isLuminanceReduced diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index ffc87d28..dda1dade 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -276,7 +276,7 @@ "telemetry.config"="Telemetry Config"; "timeout"="Timeout"; "timestamp"="Timestamp"; -"tip.bluetooth.connect.title"="Connected LoRa Radio"; +"tip.bluetooth.connect.title"="Connected Radio"; "tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity."; "tip.channels.share.title"="Sharing Meshtastic Channels"; "tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";