mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Added preview for NodeListItem, Added CoreData bindings, Align all icons, Deduplicate code for list items, Fix list view padding for tab bar transparency
447 lines
14 KiB
Swift
447 lines
14 KiB
Swift
//
|
|
// NodeListSplit.swift
|
|
// Meshtastic
|
|
//
|
|
// Created by Garth Vander Houwen on 9/8/23.
|
|
//
|
|
import SwiftUI
|
|
import CoreLocation
|
|
import OSLog
|
|
|
|
struct NodeList: View {
|
|
@Environment(\.managedObjectContext)
|
|
var context
|
|
|
|
@EnvironmentObject
|
|
var bleManager: BLEManager
|
|
|
|
@ObservedObject
|
|
var router: Router
|
|
|
|
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
|
@State private var selectedNode: NodeInfoEntity?
|
|
@State private var searchText = ""
|
|
@State private var viaLora = true
|
|
@State private var viaMqtt = true
|
|
@State private var isOnline = false
|
|
@State private var isPkiEncrypted = false
|
|
@State private var isFavorite = false
|
|
@State private var isIgnored = false
|
|
@State private var isEnvironment = false
|
|
@State private var distanceFilter = false
|
|
@State private var maxDistance: Double = 800000
|
|
@State private var hopsAway: Double = -1.0
|
|
@State private var roleFilter = false
|
|
@State private var deviceRoles: Set<Int> = []
|
|
@State private var isPresentingTraceRouteSentAlert = false
|
|
@State private var isPresentingPositionSentAlert = false
|
|
@State private var isPresentingPositionFailedAlert = false
|
|
@State private var isPresentingDeleteNodeAlert = false
|
|
@State private var deleteNodeId: Int64 = 0
|
|
|
|
var boolFilters: [Bool] {[
|
|
isFavorite,
|
|
isIgnored,
|
|
isOnline,
|
|
isPkiEncrypted,
|
|
isEnvironment,
|
|
distanceFilter,
|
|
roleFilter
|
|
]}
|
|
|
|
@State var isEditingFilters = false
|
|
|
|
@SceneStorage("selectedDetailView") var selectedDetailView: String?
|
|
|
|
@FetchRequest(
|
|
sortDescriptors: [
|
|
NSSortDescriptor(key: "ignored", ascending: true),
|
|
NSSortDescriptor(key: "favorite", ascending: false),
|
|
NSSortDescriptor(key: "lastHeard", ascending: false),
|
|
NSSortDescriptor(key: "user.longName", ascending: true)
|
|
],
|
|
animation: .spring
|
|
)
|
|
var nodes: FetchedResults<NodeInfoEntity>
|
|
|
|
var connectedNode: NodeInfoEntity? {
|
|
getNodeInfo(
|
|
id: bleManager.connectedPeripheral?.num ?? 0,
|
|
context: context
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
func contextMenuActions(
|
|
node: NodeInfoEntity,
|
|
connectedNode: NodeInfoEntity?
|
|
) -> some View {
|
|
/// Allow users to mute notifications for a node even if they are not connected
|
|
if let user = node.user {
|
|
NodeAlertsButton(
|
|
context: context,
|
|
node: node,
|
|
user: user
|
|
)
|
|
}
|
|
if let connectedNode {
|
|
/// Favoriting a node requires being connected
|
|
FavoriteNodeButton(
|
|
bleManager: bleManager,
|
|
context: context,
|
|
node: node
|
|
)
|
|
/// Don't show message, trace route, position exchange or delete context menu items for the connected node
|
|
if connectedNode.num != node.num {
|
|
if !node.viaMqtt || node.viaMqtt && node.hopsAway == 0 {
|
|
Button(action: {
|
|
if let url = URL(string: "meshtastic:///messages?userNum=\(node.num)") {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}) {
|
|
Label("Message", systemImage: "message")
|
|
}
|
|
}
|
|
Button {
|
|
let traceRouteSent = bleManager.sendTraceRouteRequest(
|
|
destNum: node.num,
|
|
wantResponse: true
|
|
)
|
|
if traceRouteSent {
|
|
isPresentingTraceRouteSentAlert = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
isPresentingTraceRouteSentAlert = false
|
|
}
|
|
}
|
|
|
|
} label: {
|
|
Label("Trace Route", systemImage: "signpost.right.and.left")
|
|
}
|
|
Button {
|
|
let positionSent = bleManager.sendPosition(
|
|
channel: node.channel,
|
|
destNum: node.num,
|
|
wantResponse: true
|
|
)
|
|
if positionSent {
|
|
isPresentingPositionSentAlert = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
isPresentingPositionSentAlert = false
|
|
}
|
|
} else {
|
|
isPresentingPositionFailedAlert = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
isPresentingPositionFailedAlert = false
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Exchange Positions", systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
IgnoreNodeButton(
|
|
bleManager: bleManager,
|
|
context: context,
|
|
node: node
|
|
)
|
|
Button(role: .destructive) {
|
|
deleteNodeId = node.num
|
|
isPresentingDeleteNodeAlert = true
|
|
} label: {
|
|
Label("Delete Node", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
List(nodes, id: \.self, selection: $selectedNode) { node in
|
|
NodeListItem(
|
|
node: node,
|
|
connected: bleManager.connectedPeripheral?.num ?? -1 == node.num,
|
|
connectedNode: bleManager.connectedPeripheral?.num ?? -1
|
|
)
|
|
.contextMenu {
|
|
contextMenuActions(
|
|
node: node,
|
|
connectedNode: connectedNode
|
|
)
|
|
}
|
|
}
|
|
.sheet(isPresented: $isEditingFilters) {
|
|
NodeListFilter(
|
|
viaLora: $viaLora,
|
|
viaMqtt: $viaMqtt,
|
|
isOnline: $isOnline,
|
|
isPkiEncrypted: $isPkiEncrypted,
|
|
isFavorite: $isFavorite,
|
|
isIgnored: $isIgnored,
|
|
isEnvironment: $isEnvironment,
|
|
distanceFilter: $distanceFilter,
|
|
maximumDistance: $maxDistance,
|
|
hopsAway: $hopsAway,
|
|
roleFilter: $roleFilter,
|
|
deviceRoles: $deviceRoles
|
|
)
|
|
}
|
|
.safeAreaInset(edge: .bottom, alignment: .trailing) {
|
|
HStack {
|
|
Button(action: {
|
|
withAnimation {
|
|
isEditingFilters = !isEditingFilters
|
|
}
|
|
}) {
|
|
Image(systemName: !isEditingFilters ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
|
|
.padding(.vertical, 5)
|
|
}
|
|
.tint(Color(UIColor.secondarySystemBackground))
|
|
.foregroundColor(.accentColor)
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.controlSize(.regular)
|
|
.padding(5)
|
|
}
|
|
.searchable(text: $searchText, placement: .automatic, prompt: "Find a node")
|
|
.disableAutocorrection(true)
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.navigationTitle(String.localizedStringWithFormat("nodes %@".localized, String(nodes.count)))
|
|
.listStyle(.plain)
|
|
.alert(
|
|
"Position Exchange Requested",
|
|
isPresented: $isPresentingPositionSentAlert) {
|
|
Button("OK") { }.keyboardShortcut(.defaultAction)
|
|
} message: {
|
|
Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.")
|
|
}
|
|
.alert(
|
|
"Position Exchange Failed",
|
|
isPresented: $isPresentingPositionFailedAlert) {
|
|
Button("OK") { }.keyboardShortcut(.defaultAction)
|
|
} message: {
|
|
Text("Failed to get a valid position to exchange")
|
|
}
|
|
.alert(
|
|
"Trace Route Sent",
|
|
isPresented: $isPresentingTraceRouteSentAlert) {
|
|
Button("OK") { }.keyboardShortcut(.defaultAction)
|
|
} message: {
|
|
Text("This could take a while, response will appear in the trace route log for the node it was sent to.")
|
|
}
|
|
.confirmationDialog(
|
|
"are.you.sure",
|
|
isPresented: $isPresentingDeleteNodeAlert,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete Node") {
|
|
let deleteNode = getNodeInfo(id: deleteNodeId, context: context)
|
|
if connectedNode != nil {
|
|
if deleteNode != nil {
|
|
let success = bleManager.removeNode(node: deleteNode!, connectedNodeNum: Int64(bleManager.connectedPeripheral?.num ?? -1))
|
|
if !success {
|
|
Logger.data.error("Failed to delete node \(deleteNode?.user?.longName ?? "unknown".localized)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500)
|
|
.navigationBarItems(
|
|
leading: MeshtasticLogo(),
|
|
trailing: ZStack {
|
|
ConnectedDevice(
|
|
bluetoothOn: bleManager.isSwitchedOn,
|
|
deviceConnected: bleManager.connectedPeripheral != nil,
|
|
name: bleManager.connectedPeripheral?.shortName ?? "?",
|
|
phoneOnly: true
|
|
)
|
|
}
|
|
)
|
|
} content: {
|
|
if let node = selectedNode {
|
|
NavigationStack {
|
|
NodeDetail(
|
|
connectedNode: connectedNode,
|
|
node: node,
|
|
columnVisibility: columnVisibility
|
|
)
|
|
.edgesIgnoringSafeArea([.leading, .trailing])
|
|
.navigationBarTitle(String(node.user?.longName ?? "unknown".localized), displayMode: .inline)
|
|
.navigationBarItems(
|
|
trailing: ZStack {
|
|
if UIDevice.current.userInterfaceIdiom != .phone {
|
|
Button {
|
|
columnVisibility = .detailOnly
|
|
} label: {
|
|
Image(systemName: "rectangle")
|
|
}
|
|
}
|
|
ConnectedDevice(
|
|
bluetoothOn: bleManager.isSwitchedOn,
|
|
deviceConnected: bleManager.connectedPeripheral != nil,
|
|
name: bleManager.connectedPeripheral?.shortName ?? "?",
|
|
phoneOnly: true
|
|
)
|
|
}
|
|
)
|
|
}
|
|
} else {
|
|
ContentUnavailableView("select.node", systemImage: "flipphone")
|
|
}
|
|
} detail: {
|
|
ContentUnavailableView("", systemImage: "line.3.horizontal")
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
.onChange(of: searchText) {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: viaLora) {
|
|
if !viaLora && !viaMqtt {
|
|
viaMqtt = true
|
|
}
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: viaMqtt) {
|
|
if !viaLora && !viaMqtt {
|
|
viaLora = true
|
|
}
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: [boolFilters]) {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: [deviceRoles]) {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: hopsAway) {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: maxDistance) {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: distanceFilter) {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
.onChange(of: router.navigationState) {
|
|
if let selected = router.navigationState.nodeListSelectedNodeNum {
|
|
self.selectedNode = getNodeInfo(id: selected, context: context)
|
|
} else {
|
|
self.selectedNode = nil
|
|
}
|
|
}
|
|
.onAppear {
|
|
Task {
|
|
await searchNodeList()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func searchNodeList() async {
|
|
/// Case Insensitive Search Text Predicates
|
|
let searchPredicates = ["user.userId", "user.numString", "user.hwModel", "user.hwDisplayName", "user.longName", "user.shortName"].map { property in
|
|
return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText)
|
|
}
|
|
/// Create a compound predicate using each text search preicate as an OR
|
|
let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates)
|
|
/// Create an array of predicates to hold our AND predicates
|
|
var predicates: [NSPredicate] = []
|
|
/// Mqtt
|
|
if !(viaLora && viaMqtt) {
|
|
if viaLora {
|
|
let loraPredicate = NSPredicate(format: "viaMqtt == NO")
|
|
predicates.append(loraPredicate)
|
|
} else {
|
|
let mqttPredicate = NSPredicate(format: "viaMqtt == YES")
|
|
predicates.append(mqttPredicate)
|
|
}
|
|
}
|
|
/// Role
|
|
if roleFilter && deviceRoles.count > 0 {
|
|
var rolesArray: [NSPredicate] = []
|
|
for dr in deviceRoles {
|
|
let deviceRolePredicate = NSPredicate(format: "user.role == %i", Int32(dr))
|
|
rolesArray.append(deviceRolePredicate)
|
|
}
|
|
let compoundPredicate = NSCompoundPredicate(type: .or, subpredicates: rolesArray)
|
|
predicates.append(compoundPredicate)
|
|
}
|
|
/// Hops Away
|
|
if hopsAway == 0.0 {
|
|
let hopsAwayPredicate = NSPredicate(format: "hopsAway == %i", Int32(hopsAway))
|
|
predicates.append(hopsAwayPredicate)
|
|
} else if hopsAway > -1.0 {
|
|
let hopsAwayPredicate = NSPredicate(format: "hopsAway > 0 AND hopsAway <= %i", Int32(hopsAway))
|
|
predicates.append(hopsAwayPredicate)
|
|
}
|
|
/// Online
|
|
if isOnline {
|
|
let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate)
|
|
predicates.append(isOnlinePredicate)
|
|
}
|
|
/// Encrypted
|
|
if isPkiEncrypted {
|
|
let isPkiEncryptedPredicate = NSPredicate(format: "user.pkiEncrypted == YES")
|
|
predicates.append(isPkiEncryptedPredicate)
|
|
}
|
|
/// Favorites
|
|
if isFavorite {
|
|
let isFavoritePredicate = NSPredicate(format: "favorite == YES")
|
|
predicates.append(isFavoritePredicate)
|
|
}
|
|
/// Ignored
|
|
if isIgnored {
|
|
let isIgnoredPredicate = NSPredicate(format: "ignored == YES")
|
|
predicates.append(isIgnoredPredicate)
|
|
} else if !isIgnored {
|
|
let isIgnoredPredicate = NSPredicate(format: "ignored == NO")
|
|
predicates.append(isIgnoredPredicate)
|
|
}
|
|
/// Environment
|
|
if isEnvironment {
|
|
let environmentPredicate = NSPredicate(format: "SUBQUERY(telemetries, $tel, $tel.metricsType == 1).@count > 0")
|
|
predicates.append(environmentPredicate)
|
|
}
|
|
/// Distance
|
|
if distanceFilter {
|
|
let pointOfInterest = LocationsHandler.currentLocation
|
|
|
|
if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude {
|
|
let d: Double = maxDistance * 1.1
|
|
let r: Double = 6371009
|
|
let meanLatitidue = pointOfInterest.latitude * .pi / 180
|
|
let deltaLatitude = d / r * 180 / .pi
|
|
let deltaLongitude = d / (r * cos(meanLatitidue)) * 180 / .pi
|
|
let minLatitude: Double = pointOfInterest.latitude - deltaLatitude
|
|
let maxLatitude: Double = pointOfInterest.latitude + deltaLatitude
|
|
let minLongitude: Double = pointOfInterest.longitude - deltaLongitude
|
|
let maxLongitude: Double = pointOfInterest.longitude + deltaLongitude
|
|
let distancePredicate = NSPredicate(format: "(SUBQUERY(positions, $position, $position.latest == TRUE && (%lf <= ($position.longitudeI / 1e7)) AND (($position.longitudeI / 1e7) <= %lf) AND (%lf <= ($position.latitudeI / 1e7)) AND (($position.latitudeI / 1e7) <= %lf))).@count > 0", minLongitude, maxLongitude, minLatitude, maxLatitude)
|
|
predicates.append(distancePredicate)
|
|
}
|
|
}
|
|
if predicates.count > 0 || !searchText.isEmpty {
|
|
if !searchText.isEmpty {
|
|
let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates)
|
|
nodes.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: [textSearchPredicate, filterPredicates])
|
|
} else {
|
|
nodes.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates)
|
|
}
|
|
} else {
|
|
nodes.nsPredicate = nil
|
|
}
|
|
}
|
|
}
|