mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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:
parent
077cd73129
commit
9fb63c4b60
6 changed files with 276 additions and 383 deletions
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift
Normal file
51
Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue