Contact list filters

This commit is contained in:
Garth Vander Houwen 2024-04-02 11:16:32 -07:00
parent e68734135b
commit 8a214d93eb
19 changed files with 173 additions and 34 deletions

View file

@ -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)))
}
}

View file

@ -15,12 +15,11 @@ extension PositionEntity {
static func allPositionsFetchRequest() -> NSFetchRequest<PositionEntity> {
let request: NSFetchRequest<PositionEntity> = 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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)

View file

@ -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<String> {
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
}
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -243,6 +243,7 @@
"network.config"="הגדרות רשת";
"nodes"="מכשירים";
"nodes %@"="מכשירים (%@)";
"nodelist.filter.distance %@"="up to %@ away";
"no.nodes"="לא נמצאו מכשירי משטסטיק";
"not.connected"="אין מכשיר מחובר";
"numbers.punctuation"="מספרים וסימני פיסוק ";

View file

@ -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";

@ -1 +1 @@
Subproject commit dea3a82ef2accd25112b4ef1c6f8991b579740f4
Subproject commit e6b4c590e7c489306c9c44e3ad1fcf62a3efd288

View file

@ -239,6 +239,7 @@
"network.config"="网络配置";
"nodes"="节点";
"nodes %@"="节点 (%@)";
"nodelist.filter.distance %@"="up to %@ away";
"no.nodes"="未找到 Meshtastic 节点";
"not.connected"="未连接到电台";
"numbers.punctuation"="数字和标点符号";

View file

@ -238,6 +238,7 @@
"network.config"="網路設定";
"nodes"="中繼點";
"nodes %@"="中繼點 (%@)";
"nodelist.filter.distance %@"="up to %@ away";
"no.nodes"="未找到 Meshtastic 中繼點";
"not.connected"="未連接到電台";
"numbers.punctuation"="數字和標點符號";