Initial try of a new node list filter pattern (#1372)

* Initial try of a new node list filter pattern

* Fix for node search

* Update user list filtering pattern

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
This commit is contained in:
Garth Vander Houwen 2025-09-07 12:57:38 -07:00 committed by GitHub
parent 077cd73129
commit 9fb63c4b60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 276 additions and 383 deletions

View file

@ -17,6 +17,7 @@
10D109F42E2047D600536CE6 /* DatadogTrace in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F32E2047D600536CE6 /* DatadogTrace */; };
230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */; };
231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */; };
231A53782E69ADB900216B99 /* NodeFilterParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */; };
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; };
@ -321,6 +322,7 @@
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = "<group>"; };
231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = "<group>"; };
231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = "<group>"; };
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
@ -1303,6 +1305,7 @@
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */,
DDDB26412AABF655003AFCB7 /* NodeListItem.swift */,
231A53772E69ADB900216B99 /* NodeFilterParameters.swift */,
DDDCD56F2BB26F5C00BE6B60 /* NodeListFilter.swift */,
251926882C3BAF2E00249DF5 /* Actions */,
BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */,
@ -1676,6 +1679,7 @@
DD3CC6C228EB9D4900FA9159 /* UpdateCoreData.swift in Sources */,
DDE0F7C5295F77B700B8AAB3 /* AppSettingsEnums.swift in Sources */,
DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */,
231A53782E69ADB900216B99 /* NodeFilterParameters.swift in Sources */,
232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */,
DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */,
BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */,

View file

@ -11,194 +11,172 @@ import OSLog
import TipKit
struct UserList: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@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 editingFilters = false
@State private var showingHelp = false
@State private var showingTrustConfirm: Bool = false
var boolFilters: [Bool] {[
isFavorite,
isOnline,
isEnvironment,
distanceFilter,
roleFilter
]}
@StateObject private var filters: NodeFilterParameters = NodeFilterParameters()
@Binding var node: NodeInfoEntity?
@Binding var userSelection: UserEntity?
@State private var isPresentingDeleteUserMessagesConfirm: Bool = false
private func fetchUsers(withFilters: NodeFilterParameters) -> [UserEntity] {
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "userNode.favorite", ascending: false),
NSSortDescriptor(key: "pkiEncrypted", ascending: false),
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)
]
request.predicate = withFilters.buildPredicate()
return (try? context.fetch(request)) ?? []
}
var body: some View {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
let users = fetchUsers(withFilters: filters)
VStack {
FilteredUserList(
searchText: searchText,
viaLora: viaLora,
viaMqtt: viaMqtt,
isOnline: isOnline,
isPkiEncrypted: isPkiEncrypted,
isFavorite: isFavorite,
isIgnored: isIgnored,
isEnvironment: isEnvironment,
distanceFilter: distanceFilter,
maxDistance: maxDistance,
hopsAway: hopsAway,
roleFilter: roleFilter,
deviceRoles: deviceRoles,
userSelection: $userSelection
) { users in
List(users, selection: $userSelection) { (user: UserEntity) in
let mostRecent = user.messageList.last
let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 ))))
let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0
let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0
if user.num != accessoryManager.activeDeviceNum ?? 0 {
NavigationLink(value: user) {
ZStack {
Image(systemName: "circle.fill")
.opacity(user.unreadMessages > 0 ? 1 : 0)
.font(.system(size: 10))
.foregroundColor(.accentColor)
.brightness(0.2)
}
CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num))))
VStack(alignment: .leading) {
HStack {
if user.pkiEncrypted {
if !user.keyMatch {
/// Public Key on the User and the Public Key on the Last Message don't match
Image(systemName: "key.slash")
.foregroundColor(.red)
} else {
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
List(users, selection: $userSelection) { user in
let mostRecent = user.messageList.last
let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 ))))
let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0
let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0
if user.num != accessoryManager.activeDeviceNum ?? 0 {
NavigationLink(value: user) {
ZStack {
Image(systemName: "circle.fill")
.opacity(user.unreadMessages > 0 ? 1 : 0)
.font(.system(size: 10))
.foregroundColor(.accentColor)
.brightness(0.2)
}
CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num))))
VStack(alignment: .leading) {
HStack {
if user.pkiEncrypted {
if !user.keyMatch {
/// Public Key on the User and the Public Key on the Last Message don't match
Image(systemName: "key.slash")
.foregroundColor(.red)
} else {
Image(systemName: "lock.open.fill")
.foregroundColor(.yellow)
}
Text(user.longName ?? "Unknown".localized)
.font(.headline)
.allowsTightening(true)
Spacer()
if user.userNode?.favorite ?? false {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
if user.messageList.count > 0 {
if lastMessageDay == currentDay {
Text(lastMessageTime, style: .time )
.font(.footnote)
.foregroundColor(.secondary)
} else if lastMessageDay == (currentDay - 1) {
Text("Yesterday")
.font(.footnote)
.foregroundColor(.secondary)
} else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) {
Text(lastMessageTime.formattedDate(format: dateFormatString))
.font(.footnote)
.foregroundColor(.secondary)
} else if lastMessageDay < (currentDay - 1800) {
Text(lastMessageTime.formattedDate(format: dateFormatString))
.font(.footnote)
.foregroundColor(.secondary)
}
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
} else {
Image(systemName: "lock.open.fill")
.foregroundColor(.yellow)
}
Text(user.longName ?? "Unknown".localized)
.font(.headline)
.allowsTightening(true)
Spacer()
if user.userNode?.favorite ?? false {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
if user.messageList.count > 0 {
HStack(alignment: .top) {
Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")")
if lastMessageDay == currentDay {
Text(lastMessageTime, style: .time )
.font(.footnote)
.foregroundColor(.secondary)
} else if lastMessageDay == (currentDay - 1) {
Text("Yesterday")
.font(.footnote)
.foregroundColor(.secondary)
} else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) {
Text(lastMessageTime.formattedDate(format: dateFormatString))
.font(.footnote)
.foregroundColor(.secondary)
} else if lastMessageDay < (currentDay - 1800) {
Text(lastMessageTime.formattedDate(format: dateFormatString))
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
}
.frame(height: 62)
.contextMenu {
Button {
if node != nil && !(user.userNode?.favorite ?? false) {
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
Task {
try await accessoryManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
Logger.data.info("Favorited a node")
}
} else {
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
Task {
try await accessoryManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
Logger.data.info("Unfavorited a node")
}
}
context.refresh(user, mergeChanges: true)
do {
try context.save()
} catch {
context.rollback()
Logger.data.error("Save Node Favorite Error")
}
} label: {
Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill")
}
Button {
user.mute = !user.mute
do {
try context.save()
} catch {
context.rollback()
Logger.data.error("Save User Mute Error")
}
} label: {
Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash")
}
if user.messageList.count > 0 {
Button(role: .destructive) {
isPresentingDeleteUserMessagesConfirm = true
userSelection = user
} label: {
Label("Delete Messages", systemImage: "trash")
HStack(alignment: .top) {
Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
.confirmationDialog(
"This conversation will be deleted.",
isPresented: $isPresentingDeleteUserMessagesConfirm,
titleVisibility: .visible
) {
Button(role: .destructive) {
deleteUserMessages(user: userSelection!, context: context)
context.refresh(node!.user!, mergeChanges: true)
} label: {
Text("Delete")
}
}
}
.frame(height: 62)
.contextMenu {
Button {
if node != nil && !(user.userNode?.favorite ?? false) {
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
Task {
try await accessoryManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
Logger.data.info("Favorited a node")
}
} else {
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
Task {
try await accessoryManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
Logger.data.info("Unfavorited a node")
}
}
context.refresh(user, mergeChanges: true)
do {
try context.save()
} catch {
context.rollback()
Logger.data.error("Save Node Favorite Error")
}
} label: {
Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill")
}
Button {
user.mute = !user.mute
do {
try context.save()
} catch {
context.rollback()
Logger.data.error("Save User Mute Error")
}
} label: {
Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash")
}
if user.messageList.count > 0 {
Button(role: .destructive) {
isPresentingDeleteUserMessagesConfirm = true
userSelection = user
} label: {
Label("Delete Messages", systemImage: "trash")
}
}
}
.confirmationDialog(
"This conversation will be deleted.",
isPresented: $isPresentingDeleteUserMessagesConfirm,
titleVisibility: .visible
) {
Button(role: .destructive) {
deleteUserMessages(user: userSelection!, context: context)
context.refresh(node!.user!, mergeChanges: true)
} label: {
Text("Delete")
}
}
}
.listStyle(.plain)
.navigationTitle(String.localizedStringWithFormat("Contacts (%@)", String(users.count)))
}
.listStyle(.plain)
.navigationTitle(String.localizedStringWithFormat("Contacts (%@)", String(users.count)))
.sheet(isPresented: $editingFilters) {
NodeListFilter(filterTitle: "Contact Filters", viaLora: $viaLora, viaMqtt: $viaMqtt, isOnline: $isOnline, isPkiEncrypted: $isPkiEncrypted, isFavorite: $isFavorite, isIgnored: $isIgnored, isEnvironment: $isEnvironment, distanceFilter: $distanceFilter, maximumDistance: $maxDistance, hopsAway: $hopsAway, roleFilter: $roleFilter, deviceRoles: $deviceRoles)
NodeListFilter(filterTitle: "Contact Filters", filters: filters)
}
.sheet(isPresented: $showingHelp) {
DirectMessagesHelp()
@ -233,40 +211,15 @@ struct UserList: View {
.padding(5)
}
.padding(.bottom, 5)
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a contact")
.disableAutocorrection(true)
.scrollDismissesKeyboard(.immediately)
.searchable(text: $filters.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a contact")
.disableAutocorrection(true)
.scrollDismissesKeyboard(.immediately)
}
}
}
struct FilteredUserList<Content: View>: View {
@FetchRequest var fetchRequest: FetchedResults<UserEntity>
let content: (FetchedResults<UserEntity>) -> Content
var body: some View {
content(fetchRequest)
}
init(
searchText: String,
viaLora: Bool,
viaMqtt: Bool,
isOnline: Bool,
isPkiEncrypted: Bool,
isFavorite: Bool,
isIgnored: Bool,
isEnvironment: Bool,
distanceFilter: Bool,
maxDistance: Double,
hopsAway: Double,
roleFilter: Bool,
deviceRoles: Set<Int>,
userSelection: Binding<UserEntity?>,
@ViewBuilder content: @escaping (FetchedResults<UserEntity>) -> Content
) {
self.content = content
// Build predicates based on filter variables
fileprivate extension NodeFilterParameters {
func buildPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
// Search text predicates
if !searchText.isEmpty {
@ -346,19 +299,9 @@ struct FilteredUserList<Content: View>: View {
predicates.append(isIgnoredPredicate)
let isConnectedNodePredicate = NSPredicate(format: "NOT (numString CONTAINS %@)", String(UserDefaults.preferredPeripheralNum))
predicates.append(isConnectedNodePredicate)
// Combine all predicates
let finalPredicate = predicates.isEmpty ? NSPredicate(value: true) : NSCompoundPredicate(type: .and, subpredicates: predicates)
// Initialize the fetch request with the combined predicate
_fetchRequest = FetchRequest<UserEntity>(
sortDescriptors: [
NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "userNode.favorite", ascending: false),
NSSortDescriptor(key: "pkiEncrypted", ascending: false),
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)
],
predicate: finalPredicate,
animation: .spring
)
return finalPredicate
}
}

View file

@ -0,0 +1,51 @@
//
// NodeListFilterParameters.swift
// Meshtastic
//
// Created by jake on 9/4/25.
//
import SwiftUI
@MainActor
final class NodeFilterParameters: ObservableObject {
// Public variables
@Published var searchText = ""
@Published var isOnline = false
@Published var isPkiEncrypted = false
@Published var isFavorite = false
@Published var isIgnored = false
@Published var isEnvironment = false
@Published var distanceFilter = false
@Published var maxDistance: Double = 800_000
@Published var hopsAway: Double = -1.0
@Published var roleFilter = false
@Published var deviceRoles: Set<Int> = []
// Private backing vars
@Published private var _viaLora = true
@Published private var _viaMqtt = true
// Public computed wrappers with enforcement
var viaLora: Bool {
get { _viaLora }
set {
objectWillChange.send()
_viaLora = newValue
if !_viaLora && !_viaMqtt {
_viaMqtt = true // enforce at least one ON
}
}
}
var viaMqtt: Bool {
get { _viaMqtt }
set {
objectWillChange.send()
_viaMqtt = newValue
if !_viaLora && !_viaMqtt {
_viaLora = true // enforce at least one ON
}
}
}
}

View file

@ -12,25 +12,26 @@ struct NodeListFilter: View {
@Environment(\.dismiss) private var dismiss
@State var editMode = EditMode.active
var filterTitle = "Node Filters"
@Binding var viaLora: Bool
@Binding var viaMqtt: Bool
@Binding var isOnline: Bool
@Binding var isPkiEncrypted: Bool
@Binding var isFavorite: Bool
@Binding var isIgnored: Bool
@Binding var isEnvironment: Bool
@Binding var distanceFilter: Bool
@Binding var maximumDistance: Double
@Binding var hopsAway: Double
@Binding var roleFilter: Bool
@Binding var deviceRoles: Set<Int>
// @Binding var viaLora: Bool
// @Binding var viaMqtt: Bool
// @Binding var isOnline: Bool
// @Binding var isPkiEncrypted: Bool
// @Binding var isFavorite: Bool
// @Binding var isIgnored: Bool
// @Binding var isEnvironment: Bool
// @Binding var distanceFilter: Bool
// @Binding var maximumDistance: Double
// @Binding var hopsAway: Double
// @Binding var roleFilter: Bool
// @Binding var deviceRoles: Set<Int>
@ObservedObject var filters: NodeFilterParameters
var body: some View {
NavigationStack {
Form {
Section(header: Text(filterTitle)) {
Toggle(isOn: $viaLora) {
Toggle(isOn: $filters.viaLora) {
Label {
Text("Via Lora")
@ -41,7 +42,7 @@ struct NodeListFilter: View {
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $viaMqtt) {
Toggle(isOn: $filters.viaMqtt) {
Label {
Text("Via Mqtt")
@ -53,7 +54,7 @@ struct NodeListFilter: View {
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
Toggle(isOn: $isOnline) {
Toggle(isOn: $filters.isOnline) {
Label {
Text("Online")
@ -66,7 +67,7 @@ struct NodeListFilter: View {
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
Toggle(isOn: $isPkiEncrypted) {
Toggle(isOn: $filters.isPkiEncrypted) {
Label {
Text("Encrypted")
@ -79,7 +80,7 @@ struct NodeListFilter: View {
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
Toggle(isOn: $isFavorite) {
Toggle(isOn: $filters.isFavorite) {
Label {
Text("Favorites")
@ -93,7 +94,7 @@ struct NodeListFilter: View {
.listRowSeparator(.visible)
if filterTitle == "Node Filters" {
Toggle(isOn: $isIgnored) {
Toggle(isOn: $filters.isIgnored) {
Label {
Text("Ignored")
@ -106,7 +107,7 @@ struct NodeListFilter: View {
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
Toggle(isOn: $isEnvironment) {
Toggle(isOn: $filters.isEnvironment) {
Label {
Text("Environment")
} icon: {
@ -117,7 +118,7 @@ struct NodeListFilter: View {
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
}
Toggle(isOn: $distanceFilter) {
Toggle(isOn: $filters.distanceFilter) {
Label {
Text("Distance")
@ -127,11 +128,11 @@ struct NodeListFilter: View {
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(distanceFilter ? .hidden : .visible)
if distanceFilter {
.listRowSeparator(filters.distanceFilter ? .hidden : .visible)
if filters.distanceFilter {
HStack {
Label("Show nodes", systemImage: "lines.measurement.horizontal")
Picker("", selection: $maximumDistance) {
Picker("", selection: $filters.maxDistance) {
ForEach(MeshMapDistances.allCases) { di in
Text(di.description)
.tag(di.id)
@ -143,7 +144,7 @@ struct NodeListFilter: View {
VStack(alignment: .leading) {
Label("Hops Away", systemImage: "hare")
Slider(
value: $hopsAway,
value: $filters.hopsAway,
in: -1...7,
step: 1
) {
@ -153,16 +154,16 @@ struct NodeListFilter: View {
} maximumValueLabel: {
Text("7")
}
if hopsAway >= 0 {
if hopsAway == 0 {
if filters.hopsAway >= 0 {
if filters.hopsAway == 0 {
Text("Direct")
} else if hopsAway == 1 {
} else if filters.hopsAway == 1 {
Text("1 hop away")
} else {
Text("\(Int(hopsAway)) or less hops away") }
Text("\(Int(filters.hopsAway)) or less hops away") }
}
}
Toggle(isOn: $roleFilter) {
Toggle(isOn: $filters.roleFilter) {
Label {
Text("Roles")
@ -171,9 +172,9 @@ struct NodeListFilter: View {
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if roleFilter {
if filters.roleFilter {
VStack {
List(DeviceRoles.allCases, selection: $deviceRoles) { dr in
List(DeviceRoles.allCases, selection: $filters.deviceRoles) { dr in
Label {
Text("\(dr.name)")
} icon: {

View file

@ -42,19 +42,7 @@ struct MeshMap: View {
@State var newWaypointCoord: CLLocationCoordinate2D?
@State var isMeshMap = true
/// Filter
@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> = []
@StateObject var filters = NodeFilterParameters()
var body: some View {
@ -160,18 +148,7 @@ struct MeshMap: View {
}
.sheet(isPresented: $editingFilters) {
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
filters: filters
)
}
.safeAreaInset(edge: .bottom, alignment: .trailing) {

View file

@ -7,6 +7,7 @@
import SwiftUI
import CoreLocation
import OSLog
import CoreData
struct NodeList: View {
@Environment(\.managedObjectContext)
@ -18,53 +19,18 @@ struct NodeList: View {
@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
// Force refresh ID to make SwiftUI rebuild the view hierarchy
@State private var forceRefreshID = UUID()
@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
@State private var shareContactNode: NodeInfoEntity?
var boolFilters: [Bool] {[
isFavorite,
isIgnored,
isOnline,
isPkiEncrypted,
isEnvironment,
distanceFilter,
roleFilter
]}
@StateObject var filters = NodeFilterParameters()
@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? {
if let num = accessoryManager.activeDeviceNum {
return getNodeInfo(id: num, context: context)
@ -72,6 +38,18 @@ struct NodeList: View {
return nil
}
private func fetchNodes(withFilters: NodeFilterParameters) -> [NodeInfoEntity] {
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "ignored", ascending: true),
NSSortDescriptor(key: "favorite", ascending: false),
NSSortDescriptor(key: "lastHeard", ascending: false),
NSSortDescriptor(key: "user.longName", ascending: true)
]
request.predicate = withFilters.buildPredicate()
return (try? context.fetch(request)) ?? []
}
@ViewBuilder
func contextMenuActions(
node: NodeInfoEntity,
@ -140,7 +118,7 @@ struct NodeList: View {
}
var body: some View {
// Use forceRefreshID to completely rebuild the view when notifications update the selected node
let nodes = fetchNodes(withFilters: filters)
NavigationSplitView(columnVisibility: $columnVisibility) {
List(nodes, id: \.self, selection: $selectedNode) { node in
NodeListItem(
@ -157,18 +135,7 @@ struct NodeList: View {
}
.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
filters: filters
)
}
.safeAreaInset(edge: .bottom, alignment: .trailing) {
@ -188,7 +155,7 @@ struct NodeList: View {
.controlSize(.regular)
.padding(5)
}
.searchable(text: $searchText, placement: .automatic, prompt: "Find a node")
.searchable(text: $filters.searchText, placement: .automatic, prompt: "Find a node")
.disableAutocorrection(true)
.scrollDismissesKeyboard(.immediately)
.navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(nodes.count)))
@ -279,52 +246,6 @@ struct NodeList: View {
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: selectedNode) {
if selectedNode != nil {
columnVisibility = .doubleColumn
@ -335,15 +256,10 @@ struct NodeList: View {
}
.onChange(of: router.navigationState) {
if let selected = router.navigationState.nodeListSelectedNodeNum {
// Force a complete view rebuild by generating a new UUID
Logger.services.info("👷‍♂️ [App] Forcing view rebuild with new ID: \(self.forceRefreshID, privacy: .public)")
// First clear selection
self.forceRefreshID = UUID()
self.selectedNode = nil
// Then after a short delay, set the new selection. Makes it obvious to use page is refreshing too.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
// Generate another UUID to ensure view gets rebuilt
self.forceRefreshID = UUID()
self.selectedNode = getNodeInfo(id: selected, context: context)
Logger.services.info("👷‍♂️ [App] Complete view refresh with node: \(selected, privacy: .public)")
}
@ -356,31 +272,38 @@ struct NodeList: View {
NotificationCenter.default.addObserver(forName: NSNotification.Name("ForceNavigationRefresh"), object: nil, queue: .main) { notification in
if let nodeNum = notification.userInfo?["nodeNum"] as? Int64 {
// Force complete refresh of view
self.forceRefreshID = UUID()
self.selectedNode = getNodeInfo(id: nodeNum, context: self.context)
Logger.services.info("NodeList directly updated from notification for node: \(nodeNum, privacy: .public)")
}
}
Task {
await searchNodeList()
}
}
.onDisappear {
// Remove observer when view disappears
NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil)
}
}
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
}
fileprivate extension NodeFilterParameters {
func buildPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
/// Mqtt
// (same predicate logic you have, but organized in functions)
if !searchText.isEmpty {
let searchKeys = [
"user.userId", "user.numString", "user.hwModel",
"user.hwDisplayName", "user.longName", "user.shortName"
]
let textPredicates = searchKeys.map {
NSPredicate(format: "%K CONTAINS[c] %@", $0, searchText)
}
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: textPredicates))
}
if isFavorite {
predicates.append(NSPredicate(format: "favorite == YES"))
}
if !(viaLora && viaMqtt) {
if viaLora {
let loraPredicate = NSPredicate(format: "viaMqtt == NO")
@ -390,6 +313,7 @@ struct NodeList: View {
predicates.append(mqttPredicate)
}
}
/// Role
if roleFilter && deviceRoles.count > 0 {
var rolesArray: [NSPredicate] = []
@ -454,15 +378,8 @@ struct NodeList: View {
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
}
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}