From 8a214d93eb69c0939abf5dbf1519edb633049524 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 2 Apr 2024 11:16:32 -0700 Subject: [PATCH] Contact list filters --- Meshtastic/Enums/AppSettingsEnums.swift | 4 +- .../CoreData/PositionEntityExtension.swift | 3 +- .../Protobufs/meshtastic/admin.pb.swift | 4 +- Meshtastic/Protobufs/meshtastic/atak.pb.swift | 2 +- .../Protobufs/meshtastic/config.pb.swift | 8 +- Meshtastic/Protobufs/meshtastic/mesh.pb.swift | 6 +- .../Protobufs/meshtastic/telemetry.pb.swift | 4 +- Meshtastic/Views/Messages/UserList.swift | 159 ++++++++++++++++-- .../Nodes/Helpers/Map/NodeMapSwiftUI.swift | 2 +- .../Views/Nodes/Helpers/NodeListFilter.swift | 3 +- Meshtastic/Views/Nodes/MeshMap.swift | 2 +- de.lproj/Localizable.strings | 1 + en.lproj/Localizable.strings | 2 + fr.lproj/Localizable.strings | 1 + he.lproj/Localizable.strings | 1 + pl.lproj/Localizable.strings | 1 + protobufs | 2 +- zh-Hans.lproj/Localizable.strings | 1 + zh-Hant-TW.lproj/Localizable.strings | 1 + 19 files changed, 173 insertions(+), 34 deletions(-) diff --git a/Meshtastic/Enums/AppSettingsEnums.swift b/Meshtastic/Enums/AppSettingsEnums.swift index 4b0d619f..3dc3d49c 100644 --- a/Meshtastic/Enums/AppSettingsEnums.swift +++ b/Meshtastic/Enums/AppSettingsEnums.swift @@ -56,10 +56,12 @@ enum MeshMapDistances: Double, CaseIterable, Identifiable { case twoHundredMiles = 321869 case fiveHundredMiles = 804672 case oneThousandMiles = 1609000 + case fifteenHundredMiles = 2414016 + case twentyFiveHundredMiles = 4023360 var id: Double { self.rawValue } var description: String { let distanceFormatter = MKDistanceFormatter() - return "up to \(distanceFormatter.string(fromDistance: Double(self.rawValue))) away" + return String.localizedStringWithFormat("nodelist.filter.distance %@".localized, distanceFormatter.string(fromDistance: Double(self.rawValue))) } } diff --git a/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift b/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift index 2643b242..680b88bc 100644 --- a/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift @@ -15,12 +15,11 @@ extension PositionEntity { static func allPositionsFetchRequest() -> NSFetchRequest { let request: NSFetchRequest = PositionEntity.fetchRequest() request.fetchLimit = 100 - request.fetchBatchSize = 1 request.returnsObjectsAsFaults = false request.includesSubentities = true request.returnsDistinctResults = true request.sortDescriptors = [NSSortDescriptor(key: "time", ascending: false)] - let positionPredicate = NSPredicate(format: "nodePosition != nil && (nodePosition.user.shortName != nil || nodePosition.user.shortName != '') && latest == true && time >= %@", Calendar.current.date(byAdding: .day, value: -2, to: Date())! as NSDate) + let positionPredicate = NSPredicate(format: "nodePosition != nil && (nodePosition.user.shortName != nil || nodePosition.user.shortName != '') && latest == true") let pointOfInterest = LocationHelper.currentLocation diff --git a/Meshtastic/Protobufs/meshtastic/admin.pb.swift b/Meshtastic/Protobufs/meshtastic/admin.pb.swift index fa1ac990..1f2b193e 100644 --- a/Meshtastic/Protobufs/meshtastic/admin.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/admin.pb.swift @@ -350,7 +350,7 @@ struct AdminMessage { } /// - /// Clear fixed position coordinates and then set position.fixed_position = false + /// Clear fixed position coordinates and then set position.fixed_position = false var removeFixedPosition: Bool { get { if case .removeFixedPosition(let v)? = payloadVariant {return v} @@ -547,7 +547,7 @@ struct AdminMessage { /// Set fixed position data on the node and then set the position.fixed_position = true case setFixedPosition(Position) /// - /// Clear fixed position coordinates and then set position.fixed_position = false + /// Clear fixed position coordinates and then set position.fixed_position = false case removeFixedPosition(Bool) /// /// Begins an edit transaction for config, module config, owner, and channel settings changes diff --git a/Meshtastic/Protobufs/meshtastic/atak.pb.swift b/Meshtastic/Protobufs/meshtastic/atak.pb.swift index 31cf5313..f1bc14ad 100644 --- a/Meshtastic/Protobufs/meshtastic/atak.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/atak.pb.swift @@ -255,7 +255,7 @@ extension MemberRole: CaseIterable { #endif // swift(>=4.2) /// -/// Packets for the official ATAK Plugin +/// Packets for the official ATAK Plugin struct TAKPacket { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for diff --git a/Meshtastic/Protobufs/meshtastic/config.pb.swift b/Meshtastic/Protobufs/meshtastic/config.pb.swift index 7506bc88..cb99fded 100644 --- a/Meshtastic/Protobufs/meshtastic/config.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/config.pb.swift @@ -226,14 +226,14 @@ struct Config { /// /// Description: Broadcasts GPS position packets as priority. /// Technical Details: Position Mesh packets will be prioritized higher and sent more frequently by default. - /// When used in conjunction with power.is_power_saving = true, nodes will wake up, + /// When used in conjunction with power.is_power_saving = true, nodes will wake up, /// send position, and then sleep for position.position_broadcast_secs seconds. case tracker // = 5 /// /// Description: Broadcasts telemetry packets as priority. /// Technical Details: Telemetry Mesh packets will be prioritized higher and sent more frequently by default. - /// When used in conjunction with power.is_power_saving = true, nodes will wake up, + /// When used in conjunction with power.is_power_saving = true, nodes will wake up, /// send environment telemetry, and then sleep for telemetry.environment_update_interval seconds. case sensor // = 6 @@ -249,12 +249,12 @@ struct Config { /// Technical Details: Used for nodes that "only speak when spoken to" /// Turns all of the routine broadcasts but allows for ad-hoc communication /// Still rebroadcasts, but with local only rebroadcast mode (known meshes only) - /// Can be used for clandestine operation or to dramatically reduce airtime / power consumption + /// Can be used for clandestine operation or to dramatically reduce airtime / power consumption case clientHidden // = 8 /// /// Description: Broadcasts location as message to default channel regularly for to assist with device recovery. - /// Technical Details: Used to automatically send a text message to the mesh + /// Technical Details: Used to automatically send a text message to the mesh /// with the current position of the device on a frequent interval: /// "I'm lost! Position: lat / long" case lostAndFound // = 9 diff --git a/Meshtastic/Protobufs/meshtastic/mesh.pb.swift b/Meshtastic/Protobufs/meshtastic/mesh.pb.swift index 1ae038f4..a881d288 100644 --- a/Meshtastic/Protobufs/meshtastic/mesh.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/mesh.pb.swift @@ -1565,8 +1565,8 @@ struct MeshPacket { set {_uniqueStorage()._viaMqtt = newValue} } - /// - /// Hop limit with which the original packet started. Sent via LoRa using three bits in the unencrypted header. + /// + /// Hop limit with which the original packet started. Sent via LoRa using three bits in the unencrypted header. /// When receiving a packet, the difference between hop_start and hop_limit gives how many hops it traveled. var hopStart: UInt32 { get {return _storage._hopStart} @@ -2606,7 +2606,7 @@ struct DeviceMetadata { init() {} } -/// +/// /// A heartbeat message is sent to the node from the client to keep the connection alive. /// This is currently only needed to keep serial connections alive, but can be used by any PhoneAPI. struct Heartbeat { diff --git a/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift b/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift index 72d378bc..aa2ebae4 100644 --- a/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift @@ -370,7 +370,7 @@ struct Telemetry { } /// - /// Power Metrics + /// Power Metrics var powerMetrics: PowerMetrics { get { if case .powerMetrics(let v)? = variant {return v} @@ -392,7 +392,7 @@ struct Telemetry { /// Air quality metrics case airQualityMetrics(AirQualityMetrics) /// - /// Power Metrics + /// Power Metrics case powerMetrics(PowerMetrics) #if !swift(>=4.1) diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 3f10b3c0..35382a13 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -17,21 +17,16 @@ struct UserList: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @State private var searchText = "" + @State private var viaLora = true + @State private var viaMqtt = true + @State private var isOnline = false + @State private var isFavorite = false + @State private var distanceFilter = false + @State private var maxDistance: Double = 800000 + @State private var hopsAway: Int = -1 + @State private var deviceRole: Int = -1 + @State var isEditingFilters = false - var usersQuery: Binding { - Binding { - searchText - } set: { newValue in - searchText = newValue - /// Case Insensitive Search Text Predicates - let searchPredicates = ["userId", "hwModel", "longName", "shortName"].map { property in - return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText) - } - /// Create a compound predicate using each text search predicate as an OR - let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates) - users.nsPredicate = newValue.isEmpty ? nil : textSearchPredicate - } - } @FetchRequest( sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "userNode.favorite", ascending: false), @@ -172,9 +167,143 @@ struct UserList: View { } .listStyle(.plain) .navigationTitle(String.localizedStringWithFormat("contacts %@".localized, String(users.count == 0 ? 0 : users.count - 1))) - .searchable(text: usersQuery, placement: users.count > 10 ? .navigationBarDrawer(displayMode: .always) : .automatic, prompt: "Find a contact") + .sheet(isPresented: $isEditingFilters) { + NodeListFilter(filterTitle: "Contact Filters", viaLora: $viaLora, viaMqtt: $viaMqtt, isOnline: $isOnline, isFavorite: $isFavorite, distanceFilter: $distanceFilter, maximumDistance: $maxDistance, hopsAway: $hopsAway, deviceRole: $deviceRole) + } + .onChange(of: searchText) { _ in + searchUserList() + } + .onChange(of: viaLora) { _ in + if !viaLora && !viaMqtt { + viaMqtt = true + } + searchUserList() + } + .onChange(of: viaMqtt) { _ in + if !viaLora && !viaMqtt { + viaLora = true + } + searchUserList() + } + .onChange(of: deviceRole) { _ in + searchUserList() + } + .onChange(of: hopsAway) { _ in + searchUserList() + } + .onChange(of: isOnline) { _ in + searchUserList() + } + .onChange(of: isFavorite) { _ in + searchUserList() + } + .onChange(of: maxDistance) { _ in + searchUserList() + } + .onChange(of: distanceFilter) { _ in + searchUserList() + } + .onAppear { + if self.bleManager.context == nil { + self.bleManager.context = context + } + searchUserList() + } + .safeAreaInset(edge: .bottom, alignment: .trailing) { + HStack { + Button(action: { + withAnimation { + isEditingFilters = !isEditingFilters + } + }) { + Image(systemName: !isEditingFilters ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + + } + .controlSize(.regular) + .padding(5) + } + .padding(.bottom, 5) + .searchable(text: $searchText, placement: users.count > 10 ? .navigationBarDrawer(displayMode: .always) : .automatic, prompt: "Find a contact") .disableAutocorrection(true) .scrollDismissesKeyboard(.immediately) } } + + private func searchUserList() { + + /// Case Insensitive Search Text Predicates + let searchPredicates = ["userId", "numString", "hwModel", "longName", "shortName"].map { property in + return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText) + } + /// Create a compound predicate using each text search preicate as an OR + let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates) + /// Create an array of predicates to hold our AND predicates + var predicates: [NSPredicate] = [] + /// Mqtt + if !(viaLora && viaMqtt) { + if viaLora { + let loraPredicate = NSPredicate(format: "userNode.viaMqtt == NO") + predicates.append(loraPredicate) + } else { + let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES") + predicates.append(mqttPredicate) + } + } + /// Role + if deviceRole > -1 { + let rolePredicate = NSPredicate(format: "role == %i", Int32(deviceRole)) + predicates.append(rolePredicate) + } + /// Hops Away + if hopsAway > 0 { + let hopsAwayPredicate = NSPredicate(format: "userNode.hopsAway == %i", Int32(hopsAway)) + predicates.append(hopsAwayPredicate) + } + + /// Online + if isOnline { + let isOnlinePredicate = NSPredicate(format: "userNode.lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -15, to: Date())! as NSDate) + predicates.append(isOnlinePredicate) + } + /// Favorites + if isFavorite { + let isFavoritePredicate = NSPredicate(format: "userNode.favorite == YES") + predicates.append(isFavoritePredicate) + } + /// Distance + if distanceFilter { + let pointOfInterest = LocationHelper.currentLocation + + if pointOfInterest.latitude != LocationHelper.DefaultLocation.latitude && pointOfInterest.longitude != LocationHelper.DefaultLocation.longitude { + let D: Double = maxDistance * 1.1 + let R: Double = 6371009 + let meanLatitidue = pointOfInterest.latitude * .pi / 180 + let deltaLatitude = D / R * 180 / .pi + let deltaLongitude = D / (R * cos(meanLatitidue)) * 180 / .pi + let minLatitude: Double = pointOfInterest.latitude - deltaLatitude + let maxLatitude: Double = pointOfInterest.latitude + deltaLatitude + let minLongitude: Double = pointOfInterest.longitude - deltaLongitude + let maxLongitude: Double = pointOfInterest.longitude + deltaLongitude + let distancePredicate = NSPredicate(format: "(SUBQUERY(userNode.positions, $position, $position.latest == TRUE && (%lf <= ($position.longitudeI / 1e7)) AND (($position.longitudeI / 1e7) <= %lf) AND (%lf <= ($position.latitudeI / 1e7)) AND (($position.latitudeI / 1e7) <= %lf))).@count > 0", minLongitude, maxLongitude,minLatitude, maxLatitude) + predicates.append(distancePredicate) + } + } + + if predicates.count > 0 || !searchText.isEmpty { + if !searchText.isEmpty { + let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates) + users.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: [textSearchPredicate, filterPredicates]) + } else { + users.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + } + } else { + users.nsPredicate = nil + } + + } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 9cbb352b..cf983edb 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -138,7 +138,7 @@ struct NodeMapSwiftUI: View { } } } - .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { + .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { Button(action: { withAnimation { diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift index 6726c90b..321054c1 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift @@ -11,6 +11,7 @@ import SwiftUI struct NodeListFilter: View { @Environment(\.dismiss) private var dismiss /// Filters + var filterTitle = "Node Filters" @Binding var viaLora: Bool @Binding var viaMqtt: Bool @Binding var isOnline: Bool @@ -24,7 +25,7 @@ struct NodeListFilter: View { NavigationStack { Form { - Section(header: Text("Node Filters")) { + Section(header: Text(filterTitle)) { Toggle(isOn: $viaLora) { Label { diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 7b144fe1..5a848908 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -148,7 +148,7 @@ struct MeshMap: View { return } } - .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { + .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { Button(action: { withAnimation { diff --git a/de.lproj/Localizable.strings b/de.lproj/Localizable.strings index bf9e8d39..54b004f6 100644 --- a/de.lproj/Localizable.strings +++ b/de.lproj/Localizable.strings @@ -239,6 +239,7 @@ "network.config"="Netzwerkeinstellungen"; "nodes"="Nodes"; "nodes %@"="Nodes (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="Keine Meshtastic Nodes gefunden"; "not.connected"="Kein Gerät verbunden"; "numbers.punctuation"="Ziffern und Interpunktion"; diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index e031fd63..b395eb99 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -245,6 +245,8 @@ "network.config"="Network Config"; "nodes"="Nodes"; "nodes %@"="Nodes (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; +"save.config %@"="Save Config for %@"; "no.nodes"="No Meshtastic Nodes Found"; "not.connected"="No device connected"; "numbers.punctuation"="Numbers and Punctuation"; diff --git a/fr.lproj/Localizable.strings b/fr.lproj/Localizable.strings index 34a507da..717d5997 100644 --- a/fr.lproj/Localizable.strings +++ b/fr.lproj/Localizable.strings @@ -219,6 +219,7 @@ "network.config"="Configuration du réseau"; "nodes"="Noeuds"; "nodes %@"="Noeuds (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="Aucun noeud Meshtastic trouvé"; "not.connected"="Aucun appareil connecté"; "numbers.punctuation"="Nombres and Ponctuation"; diff --git a/he.lproj/Localizable.strings b/he.lproj/Localizable.strings index d7e0a05d..ce0d9316 100644 --- a/he.lproj/Localizable.strings +++ b/he.lproj/Localizable.strings @@ -243,6 +243,7 @@ "network.config"="הגדרות רשת"; "nodes"="מכשירים"; "nodes %@"="מכשירים (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="לא נמצאו מכשירי משטסטיק"; "not.connected"="אין מכשיר מחובר"; "numbers.punctuation"="מספרים וסימני פיסוק "; diff --git a/pl.lproj/Localizable.strings b/pl.lproj/Localizable.strings index 6a3b18b8..82b7aa4c 100644 --- a/pl.lproj/Localizable.strings +++ b/pl.lproj/Localizable.strings @@ -240,6 +240,7 @@ "network"="Sieć"; "network.config"="Konfiguracja sieci"; "nodes %@"="Węzły (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="Nie znaleziono węzłów Meshtastic"; "not.connected"="Brak podłączonych urządzeń"; "numbers.punctuation"="Cyfry i interpunkcja"; diff --git a/protobufs b/protobufs index dea3a82e..e6b4c590 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit dea3a82ef2accd25112b4ef1c6f8991b579740f4 +Subproject commit e6b4c590e7c489306c9c44e3ad1fcf62a3efd288 diff --git a/zh-Hans.lproj/Localizable.strings b/zh-Hans.lproj/Localizable.strings index 886cab0a..9a424c0e 100644 --- a/zh-Hans.lproj/Localizable.strings +++ b/zh-Hans.lproj/Localizable.strings @@ -239,6 +239,7 @@ "network.config"="网络配置"; "nodes"="节点"; "nodes %@"="节点 (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="未找到 Meshtastic 节点"; "not.connected"="未连接到电台"; "numbers.punctuation"="数字和标点符号"; diff --git a/zh-Hant-TW.lproj/Localizable.strings b/zh-Hant-TW.lproj/Localizable.strings index 68562e56..4a557826 100644 --- a/zh-Hant-TW.lproj/Localizable.strings +++ b/zh-Hant-TW.lproj/Localizable.strings @@ -238,6 +238,7 @@ "network.config"="網路設定"; "nodes"="中繼點"; "nodes %@"="中繼點 (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="未找到 Meshtastic 中繼點"; "not.connected"="未連接到電台"; "numbers.punctuation"="數字和標點符號";