diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 4cfe091dd..441097c13 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -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 } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 797f3973d..29bb67b85 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -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 } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsView.swift new file mode 100644 index 000000000..023765f90 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsView.swift @@ -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 + ) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift new file mode 100644 index 000000000..255a06920 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 2c2bd5c8b..cd0a339fa 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -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) }