diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 181d0bd8..27c5a4f9 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 */; }; @@ -218,6 +219,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 = ""; }; @@ -819,6 +821,7 @@ DDDB26412AABF655003AFCB7 /* NodeListItem.swift */, DDDB26472AACD6D1003AFCB7 /* NodeMapControl.swift */, DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */, + DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */, ); path = Helpers; sourceTree = ""; @@ -1106,6 +1109,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 +1414,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 +1448,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 +1570,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 +1603,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 = ""; 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..1547fe77 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -69,7 +69,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 +486,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 +504,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 +514,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: 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/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 4cf0f135..9fa4e2b8 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,47 +102,47 @@ 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 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..0545e686 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeMapSwiftUI.swift @@ -11,6 +11,7 @@ import MapKit import WeatherKit @available(iOS 17.0, macOS 14.0, *) + struct NodeMapSwiftUI: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @@ -27,6 +28,8 @@ struct NodeMapSwiftUI: View { @State private var isLookingAround = false @State private var isEditingSettings = false @State private var showUserLocation: Bool = false + + @State private var showConvexHull = true @State var selected: PositionEntity? /// Data @ObservedObject var node: NodeInfoEntity @@ -36,6 +39,8 @@ struct NodeMapSwiftUI: View { ), animation: .none) private var waypoints: FetchedResults + @State private var showingPopover = false + var body: some View { let nodeColor = UIColor(hex: UInt32(node.num)) let positionArray = node.positions?.array as? [PositionEntity] ?? [] @@ -48,17 +53,26 @@ struct NodeMapSwiftUI: View { 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) + 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 = getConvexHull(input: lineCoords) + MapPolygon(coordinates: hull) + .stroke(Color(nodeColor.darker()), lineWidth: 5) + .foregroundStyle(Color(nodeColor).opacity(0.4)) } /// Node Annotations ForEach(positionArray.reversed(), id: \.id) { position in @@ -79,10 +93,16 @@ struct NodeMapSwiftUI: View { .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)") -// } + .onTapGesture { + showingPopover = true + selected = (selected == position ? nil : position) // <-- here + } + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + PositionPopover(position: position) + .padding() + .opacity(0.8) + .presentationCompactAdaptation(.popover) + } } else { Image(systemName: "flipphone") .symbolEffect(.pulse.byLayer) @@ -90,10 +110,16 @@ struct NodeMapSwiftUI: View { .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)") -// } + .onTapGesture { + showingPopover = true + selected = (selected == position ? nil : position) // <-- here + } + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + PositionPopover(position: position) + .padding() + .opacity(0.8) + .presentationCompactAdaptation(.popover) + } } } else { if showNodeHistory { @@ -186,6 +212,14 @@ struct NodeMapSwiftUI: View { 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") } @@ -216,13 +250,13 @@ struct NodeMapSwiftUI: View { .padding() #endif } - //.presentationDetents([.fraction(0.4)]) + //.presentationDetents([.fraction(0.5)]) .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)) + 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) @@ -306,4 +340,49 @@ struct NodeMapSwiftUI: View { let lookAroundScene = MKLookAroundSceneRequest(coordinate: coordinate) return try await lookAroundScene.scene } + + func getConvexHull(input: [CLLocationCoordinate2D]) -> [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 = input.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/Views/Nodes/Helpers/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/PositionPopover.swift index 68ec2b29..58928cde 100644 --- a/Meshtastic/Views/Nodes/Helpers/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/PositionPopover.swift @@ -2,7 +2,116 @@ // PositionPopover.swift // Meshtastic // -// Created by Garth Vander Houwen on 9/17/23. +// Copyright(c) Garth Vander Houwen 9/17/23. // -import Foundation +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) + } + 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)))") + // .font(.footnote) + .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))") + // .font(.footnote) + .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))") + // .font(.footnote) + .foregroundColor(.primary) + } icon: { + Image(systemName: "number") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } + .padding(.bottom, 5) + } + /// Heading +// if pf.contains(.Heading) { +// Text("Heading: \(Int32(position.heading))") +// } + /// 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/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/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/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.";