Simplify the label filter modal, add negation

This commit is contained in:
Jackson Harper
2022-05-17 14:31:30 -07:00
parent d96a3e1fc1
commit e19711b0a4
5 changed files with 191 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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