mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge pull request #1250 from meshtastic/contactlist_filters
Contactlist filters
This commit is contained in:
commit
c9c74bcb33
1 changed files with 206 additions and 217 deletions
|
|
@ -21,7 +21,6 @@ struct UserList: View {
|
|||
@State private var isPkiEncrypted = false
|
||||
@State private var isFavorite = false
|
||||
@State private var isIgnored = false
|
||||
@State private var isUnmessagable = false
|
||||
@State private var isEnvironment = false
|
||||
@State private var distanceFilter = false
|
||||
@State private var maxDistance: Double = 800000
|
||||
|
|
@ -40,18 +39,6 @@ struct UserList: View {
|
|||
roleFilter
|
||||
]}
|
||||
|
||||
@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: NSPredicate(
|
||||
format: "userNode.ignored == NO AND unmessagable = NO"
|
||||
), animation: .spring
|
||||
)
|
||||
var users: FetchedResults<UserEntity>
|
||||
|
||||
@Binding var node: NodeInfoEntity?
|
||||
@Binding var userSelection: UserEntity?
|
||||
|
||||
|
|
@ -61,196 +48,161 @@ struct UserList: View {
|
|||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
|
||||
VStack {
|
||||
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 != bleManager.connectedPeripheral?.num ?? 0 {
|
||||
NavigationLink(value: user) {
|
||||
ZStack {
|
||||
Image(systemName: "circle.fill")
|
||||
.opacity(user.unreadMessages > 0 ? 1 : 0)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.accentColor)
|
||||
.brightness(0.2)
|
||||
}
|
||||
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 != bleManager.connectedPeripheral?.num ?? 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))))
|
||||
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)
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundColor(.green)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.messageList.count > 0 {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 62)
|
||||
.contextMenu {
|
||||
Button {
|
||||
if node != nil && !(user.userNode?.favorite ?? false) {
|
||||
let success = bleManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
if success {
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
|
||||
Logger.data.info("Favorited a node")
|
||||
}
|
||||
} 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)
|
||||
let success = bleManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
if success {
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
|
||||
Logger.data.info("Unfavorited a node")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.messageList.count > 0 {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
context.refresh(user, mergeChanges: true)
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Node Favorite Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 62)
|
||||
.contextMenu {
|
||||
Button {
|
||||
|
||||
if node != nil && !(user.userNode?.favorite ?? false) {
|
||||
let success = bleManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
if success {
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? true)
|
||||
Logger.data.info("Favorited a node")
|
||||
}
|
||||
} else {
|
||||
let success = bleManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
if success {
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? true)
|
||||
Logger.data.info("Un Favorited 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")
|
||||
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")
|
||||
.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 (%@)".localized, String(users.count == 0 ? 0 : users.count - 1)))
|
||||
.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)
|
||||
}
|
||||
.sheet(isPresented: $showingHelp) {
|
||||
DirectMessagesHelp()
|
||||
}
|
||||
.onChange(of: searchText) {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: viaLora) {
|
||||
if !viaLora && !viaMqtt {
|
||||
viaMqtt = true
|
||||
}
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: viaMqtt) {
|
||||
if !viaLora && !viaMqtt {
|
||||
viaLora = true
|
||||
}
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: [deviceRoles]) {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: hopsAway) {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: [boolFilters]) {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: maxDistance) {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onChange(of: isPkiEncrypted) {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
Task {
|
||||
await searchUserList()
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading) {
|
||||
HStack {
|
||||
Button(action: {
|
||||
|
|
@ -281,23 +233,50 @@ struct UserList: View {
|
|||
.padding(5)
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
.padding(.bottom, 5)
|
||||
.searchable(text: $searchText, placement: users.count > 10 ? .navigationBarDrawer(displayMode: .always) : .automatic, prompt: "Find a contact")
|
||||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a contact")
|
||||
.disableAutocorrection(true)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
}
|
||||
private func searchUserList() async {
|
||||
}
|
||||
|
||||
/// Case Insensitive Search Text Predicates
|
||||
let searchPredicates = ["userId", "numString", "hwModel", "hwDisplayName", "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
|
||||
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
|
||||
var predicates: [NSPredicate] = []
|
||||
/// Mqtt and lora
|
||||
// Search text predicates
|
||||
if !searchText.isEmpty {
|
||||
let searchPredicates = ["userId", "numString", "hwModel", "hwDisplayName", "longName", "shortName"].map { property in
|
||||
return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText)
|
||||
}
|
||||
let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates)
|
||||
predicates.append(textSearchPredicate)
|
||||
}
|
||||
// Mqtt and lora
|
||||
if !(viaLora && viaMqtt) {
|
||||
if viaLora {
|
||||
let loraPredicate = NSPredicate(format: "userNode.viaMqtt == NO")
|
||||
|
|
@ -306,11 +285,8 @@ struct UserList: View {
|
|||
let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES")
|
||||
predicates.append(mqttPredicate)
|
||||
}
|
||||
} else {
|
||||
let mqttPredicate = NSPredicate(format: "NOT (userNode.viaMqtt == YES)")
|
||||
predicates.append(mqttPredicate)
|
||||
}
|
||||
/// Roles
|
||||
// Roles
|
||||
if roleFilter && deviceRoles.count > 0 {
|
||||
var rolesArray: [NSPredicate] = []
|
||||
for dr in deviceRoles {
|
||||
|
|
@ -320,40 +296,40 @@ struct UserList: View {
|
|||
let compoundPredicate = NSCompoundPredicate(type: .or, subpredicates: rolesArray)
|
||||
predicates.append(compoundPredicate)
|
||||
}
|
||||
/// Hops Away
|
||||
if hopsAway == 0.0 {
|
||||
// Hops Away
|
||||
if hopsAway == 0 {
|
||||
let hopsAwayPredicate = NSPredicate(format: "userNode.hopsAway == %i", Int32(hopsAway))
|
||||
predicates.append(hopsAwayPredicate)
|
||||
} else if hopsAway > -1.0 {
|
||||
let hopsAwayPredicate = NSPredicate(format: "userNode.hopsAway > 0 AND userNode.hopsAway <= %i", Int32(hopsAway))
|
||||
predicates.append(hopsAwayPredicate)
|
||||
}
|
||||
/// Online
|
||||
// Online
|
||||
if isOnline {
|
||||
let isOnlinePredicate = NSPredicate(format: "userNode.lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate)
|
||||
predicates.append(isOnlinePredicate)
|
||||
}
|
||||
/// Encrypted
|
||||
// Encrypted
|
||||
if isPkiEncrypted {
|
||||
let isPkiEncryptedPredicate = NSPredicate(format: "pkiEncrypted == YES")
|
||||
predicates.append(isPkiEncryptedPredicate)
|
||||
}
|
||||
/// Favorites
|
||||
// Favorites
|
||||
if isFavorite {
|
||||
let isFavoritePredicate = NSPredicate(format: "userNode.favorite == YES")
|
||||
predicates.append(isFavoritePredicate)
|
||||
}
|
||||
/// Ignored
|
||||
let isIgnoredPredicate = NSPredicate(format: "userNode.ignored == NO")
|
||||
predicates.append(isIgnoredPredicate)
|
||||
/// Unmessagable
|
||||
let isUnmessagablePredicate = NSPredicate(format: "unmessagable == NO")
|
||||
predicates.append(isUnmessagablePredicate)
|
||||
|
||||
/// Distance
|
||||
// Ignored
|
||||
if isIgnored {
|
||||
let isIgnoredPredicate = NSPredicate(format: "userNode.ignored == YES")
|
||||
predicates.append(isIgnoredPredicate)
|
||||
} else if !isIgnored {
|
||||
let isIgnoredPredicate = NSPredicate(format: "userNode.ignored == NO")
|
||||
predicates.append(isIgnoredPredicate)
|
||||
}
|
||||
// Distance
|
||||
if distanceFilter {
|
||||
let pointOfInterest = LocationsHandler.currentLocation
|
||||
|
||||
if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude {
|
||||
let d: Double = maxDistance * 1.1
|
||||
let r: Double = 6371009
|
||||
|
|
@ -368,11 +344,24 @@ struct UserList: View {
|
|||
predicates.append(distancePredicate)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Always apply unmessagable and connected node filters
|
||||
let isUnmessagablePredicate = NSPredicate(format: "unmessagable == NO")
|
||||
predicates.append(isUnmessagablePredicate)
|
||||
let isConnectedNodePredicate = NSPredicate(format: "NOT (numString CONTAINS %@)", 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue