Simplify the label filter modal, add negation
This commit is contained in:
@ -34,6 +34,9 @@ private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
|
||||
.onChange(of: viewModel.selectedLabels) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.negatedLabels) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.appliedFilter) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
@ -146,16 +149,22 @@ private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
|
||||
showLabelsSheet = true
|
||||
}
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label) {
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.negatedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
|
||||
viewModel.negatedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.sheet(isPresented: $showLabelsSheet) {
|
||||
ApplyLabelsView(mode: .list(viewModel.selectedLabels)) {
|
||||
viewModel.selectedLabels = $0
|
||||
FilterByLabelsView(initiallySelected: viewModel.selectedLabels, initiallyNegated: viewModel.negatedLabels) {
|
||||
self.viewModel.selectedLabels = $0
|
||||
self.viewModel.negatedLabels = $1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import Views
|
||||
@Published var itemUnderLabelEdit: LinkedItem?
|
||||
@Published var searchTerm = ""
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
@Published var snoozePresented = false
|
||||
@Published var itemToSnoozeID: String?
|
||||
@Published var selectedLinkItem: LinkedItem?
|
||||
@ -125,6 +126,18 @@ import Views
|
||||
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
}
|
||||
|
||||
if !negatedLabels.isEmpty {
|
||||
var labelSubPredicates = [NSPredicate]()
|
||||
|
||||
for label in negatedLabels {
|
||||
labelSubPredicates.append(
|
||||
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0")
|
||||
)
|
||||
}
|
||||
|
||||
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
}
|
||||
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
|
||||
return fetchRequest
|
||||
}
|
||||
@ -192,6 +205,11 @@ import Views
|
||||
query.append(selectedLabels.map { $0.name ?? "" }.joined(separator: ","))
|
||||
}
|
||||
|
||||
if !negatedLabels.isEmpty {
|
||||
query.append(" !label:")
|
||||
query.append(negatedLabels.map { $0.name ?? "" }.joined(separator: ","))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct FilterByLabelsView: View {
|
||||
let initiallySelected: [LinkedItemLabel]
|
||||
let initiallyNegated: [LinkedItemLabel]
|
||||
let onSave: (([LinkedItemLabel], [LinkedItemLabel]) -> Void)?
|
||||
|
||||
@StateObject var viewModel = FilterByLabelsViewModel()
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
// init(initiallySelected: [LinkedItemLabel], initiallyNegated: [LinkedItemLabel], onSave:) {
|
||||
//
|
||||
// }
|
||||
|
||||
func isNegated(_ label: LinkedItemLabel) -> Bool {
|
||||
viewModel.negatedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
func isSelected(_ label: LinkedItemLabel) -> Bool {
|
||||
viewModel.selectedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
List {
|
||||
ForEach(viewModel.labels, id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label, negated: isNegated(label))
|
||||
Spacer()
|
||||
Button(action: {
|
||||
if isSelected(label) {
|
||||
viewModel.negatedLabels.append(label)
|
||||
viewModel.selectedLabels.removeAll(where: { $0.id == label.id })
|
||||
} else if isNegated(label) {
|
||||
viewModel.negatedLabels.removeAll(where: { $0.id == label.id })
|
||||
} else {
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
}, label: {
|
||||
if isNegated(label) {
|
||||
Image(systemName: "circle.slash")
|
||||
}
|
||||
if isSelected(label) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Filter by Label")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(
|
||||
action: { presentationMode.wrappedValue.dismiss() },
|
||||
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
onSave?(viewModel.selectedLabels, viewModel.negatedLabels)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
label: { Text("Done").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if viewModel.isLoading {
|
||||
EmptyView()
|
||||
} else {
|
||||
#if os(iOS)
|
||||
innerBody
|
||||
.searchable(
|
||||
text: $viewModel.labelSearchFilter,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Filter Labels"
|
||||
)
|
||||
#else
|
||||
innerBody
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadLabels(
|
||||
dataService: dataService,
|
||||
initiallySelectedLabels: initiallySelected,
|
||||
initiallyNegatedLabels: initiallyNegated
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class FilterByLabelsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var labels = [LinkedItemLabel]()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
@Published var unselectedLabels = [LinkedItemLabel]()
|
||||
@Published var labelSearchFilter = ""
|
||||
|
||||
func loadLabels(
|
||||
dataService: DataService,
|
||||
initiallySelectedLabels: [LinkedItemLabel],
|
||||
initiallyNegatedLabels: [LinkedItemLabel]
|
||||
) async {
|
||||
isLoading = true
|
||||
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
dataService.viewContext.performAndWait {
|
||||
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
|
||||
}
|
||||
|
||||
for label in labels {
|
||||
if initiallySelectedLabels.contains(label) {
|
||||
selectedLabels.append(label)
|
||||
} else if initiallyNegatedLabels.contains(label) {
|
||||
negatedLabels.append(label)
|
||||
} else {
|
||||
unselectedLabels.append(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@ -3,27 +3,35 @@ import SwiftUI
|
||||
import Utils
|
||||
|
||||
public struct TextChip: View {
|
||||
public init(text: String, color: Color) {
|
||||
public init(text: String, color: Color, negated: Bool = false) {
|
||||
self.text = text
|
||||
self.color = color
|
||||
self.negated = negated
|
||||
}
|
||||
|
||||
public init?(feedItemLabel: LinkedItemLabel) {
|
||||
public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false) {
|
||||
guard let color = Color(hex: feedItemLabel.color ?? "") else { return nil }
|
||||
|
||||
self.text = feedItemLabel.name ?? ""
|
||||
self.color = color
|
||||
self.negated = negated
|
||||
}
|
||||
|
||||
let text: String
|
||||
let color: Color
|
||||
let negated: Bool
|
||||
|
||||
var textColor: Color {
|
||||
color.isDark ? .white : .black
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.strikethrough(color: negated ? textColor : .clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(color.isDark ? .white : .black)
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(1)
|
||||
.background(Capsule().fill(color))
|
||||
}
|
||||
@ -31,25 +39,27 @@ public struct TextChip: View {
|
||||
|
||||
public struct TextChipButton: View {
|
||||
public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton {
|
||||
TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap)
|
||||
TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, negated: false, onTap: onTap)
|
||||
}
|
||||
|
||||
public static func makeFilterButton(title: String) -> TextChipButton {
|
||||
TextChipButton(title: title, color: .systemGray6, actionType: .show, onTap: {})
|
||||
TextChipButton(title: title, color: .systemGray6, actionType: .show, negated: false, onTap: {})
|
||||
}
|
||||
|
||||
public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton {
|
||||
TextChipButton(title: title, color: .appButtonBackground, actionType: .add, onTap: onTap)
|
||||
TextChipButton(title: title, color: .appButtonBackground, actionType: .add, negated: false, onTap: onTap)
|
||||
}
|
||||
|
||||
public static func makeRemovableLabelButton(
|
||||
feedItemLabel: LinkedItemLabel,
|
||||
negated: Bool,
|
||||
onTap: @escaping () -> Void
|
||||
) -> TextChipButton {
|
||||
TextChipButton(
|
||||
title: feedItemLabel.name ?? "",
|
||||
color: Color(hex: feedItemLabel.color ?? "") ?? .appButtonBackground,
|
||||
actionType: .remove,
|
||||
negated: negated,
|
||||
onTap: onTap
|
||||
)
|
||||
}
|
||||
@ -71,7 +81,7 @@ public struct TextChipButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
public init(title: String, color: Color, actionType: ActionType, onTap: @escaping () -> Void) {
|
||||
public init(title: String, color: Color, actionType: ActionType, negated: Bool, onTap: @escaping () -> Void) {
|
||||
self.text = title
|
||||
self.color = color
|
||||
self.onTap = onTap
|
||||
@ -82,9 +92,11 @@ public struct TextChipButton: View {
|
||||
}
|
||||
return color.isDark ? .white : .black
|
||||
}()
|
||||
self.negated = negated
|
||||
}
|
||||
|
||||
let text: String
|
||||
let negated: Bool
|
||||
let color: Color
|
||||
let onTap: () -> Void
|
||||
let actionType: ActionType
|
||||
@ -94,6 +106,7 @@ public struct TextChipButton: View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(text)
|
||||
.strikethrough(color: negated ? foregroundColor : .clear)
|
||||
.padding(.leading, 3)
|
||||
Image(systemName: actionType.systemIconName)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user