From 9fb63c4b6082144e82aa5e4f1b112fdbac81696d Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 7 Sep 2025 12:57:38 -0700 Subject: [PATCH] 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 --- Meshtastic.xcodeproj/project.pbxproj | 4 + Meshtastic/Views/Messages/UserList.swift | 345 ++++++++---------- .../Nodes/Helpers/NodeFilterParameters.swift | 51 +++ .../Views/Nodes/Helpers/NodeListFilter.swift | 65 ++-- Meshtastic/Views/Nodes/MeshMap.swift | 27 +- Meshtastic/Views/Nodes/NodeList.swift | 167 +++------ 6 files changed, 276 insertions(+), 383 deletions(-) create mode 100644 Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 691da218..db8fb273 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = ""; }; + 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = ""; }; 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = ""; }; 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index ae6be092..37513af0 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -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 = [] @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.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: View { - @FetchRequest var fetchRequest: FetchedResults - let content: (FetchedResults) -> 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, - userSelection: Binding, - @ViewBuilder content: @escaping (FetchedResults) -> 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: 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( - 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 } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift b/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift new file mode 100644 index 00000000..762f59e7 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift @@ -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 = [] + + // 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 + } + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift index 1a02cfd7..73ee2897 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift @@ -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 - +// @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 + @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: { diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 58f5aff6..ba7503d9 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -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 = [] + @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) { diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 40c08fa3..20a879b3 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -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 = [] @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 - 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.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) } } +