Map annotation popover and convex hull!

This commit is contained in:
Garth Vander Houwen 2023-09-17 19:42:03 -07:00
parent 41fc4574ba
commit 3a5f192ac1
13 changed files with 344 additions and 89 deletions

View file

@ -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 = "<group>"; };
DD0E9C222A30CE3A00580CBB /* MeshtasticDataModelV14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV14.xcdatamodel; sourceTree = "<group>"; };
DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminMessageList.swift; sourceTree = "<group>"; };
DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionPopover.swift; sourceTree = "<group>"; };
DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV15.xcdatamodel; sourceTree = "<group>"; };
DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfigEnums.swift; sourceTree = "<group>"; };
DD1925B828CDA93900720036 /* SerialConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfigEnums.swift; sourceTree = "<group>"; };
@ -819,6 +821,7 @@
DDDB26412AABF655003AFCB7 /* NodeListItem.swift */,
DDDB26472AACD6D1003AFCB7 /* NodeMapControl.swift */,
DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */,
DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -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 = "";

View file

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

View file

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

View file

@ -22,6 +22,6 @@ struct BluetoothConnectionTip: Tip {
Text("tip.bluetooth.connect.message")
}
var image: Image? {
Image(systemName: "questionmark.circle")
Image(systemName: "flipphone")
}
}

View file

@ -22,6 +22,6 @@
Text("tip.channels.share.message")
}
var image: Image? {
Image(systemName: "questionmark.circle")
Image(systemName: "qrcode")
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NodeInfoEntity>
var nodes: FetchedResults<NodeInfoEntity>
@ -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)))

View file

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

View file

@ -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.";