Merge pull request #399 from omnivore-app/feature/label-filtering-ios

Label filtering ios
This commit is contained in:
Satindar Dhillon
2022-04-13 20:14:54 -07:00
committed by GitHub
13 changed files with 1793 additions and 120 deletions

View File

@ -23,26 +23,29 @@ import Views
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.searchable(
text: $viewModel.searchQuery,
placement: .sidebar
text: $viewModel.searchTerm,
placement: .navigationBarDrawer
) {
if viewModel.searchQuery.isEmpty {
if viewModel.searchTerm.isEmpty {
Text("Inbox").searchCompletion("in:inbox ")
Text("All").searchCompletion("in:all ")
Text("Archived").searchCompletion("in:archive ")
Text("Files").searchCompletion("type:file ")
}
}
.onChange(of: viewModel.searchQuery) { _ in
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onChange(of: viewModel.selectedLabels) { _ in
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onSubmit(of: .search) {
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
ApplyLabelsView(mode: .item(item)) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
@ -52,7 +55,7 @@ import Views
viewModel: viewModel
)
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
ApplyLabelsView(mode: .item(item)) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
@ -71,6 +74,7 @@ import Views
}
}
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Don't refresh the list if the user is currently reading an article
if viewModel.selectedLinkItem == nil {
@ -99,55 +103,75 @@ import Views
}
}
.onChange(of: viewModel.selectedLinkItem) { _ in
viewModel.commitProgressUpdates()
viewModel.commitItemUpdates()
}
}
}
struct HomeFeedView: View {
@EnvironmentObject var dataService: DataService
@Binding var prefersListLayout: Bool
@State private var showLabelsSheet = false
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if prefersListLayout {
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
} else {
HomeFeedGridView(viewModel: viewModel)
.toolbar {
ToolbarItem {
if #available(iOS 15.0, *) {
Button("", action: {})
.disabled(true)
.overlay {
if viewModel.isLoading {
ProgressView()
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
TextChipButton.makeAddLabelButton {
showLabelsSheet = true
}
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
.padding(.horizontal)
.sheet(isPresented: $showLabelsSheet) {
ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in
viewModel.selectedLabels = labels
}
}
}
if prefersListLayout {
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
} else {
HomeFeedGridView(viewModel: viewModel)
.toolbar {
ToolbarItem {
if #available(iOS 15.0, *) {
Button("", action: {})
.disabled(true)
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
}
} else {
if viewModel.isLoading {
Button(action: {}, label: { ProgressView() })
} else {
if viewModel.isLoading {
Button(action: {}, label: { ProgressView() })
} else {
Button(
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
}
}
}
ToolbarItem {
if UIDevice.isIPad {
Button(
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
}
}
}
ToolbarItem {
if UIDevice.isIPad {
Button(
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
}
}
}
}
}
}
}
@ -170,6 +194,10 @@ import Views
viewModel: viewModel
)
.contextMenu {
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)

View File

@ -77,17 +77,17 @@ import Views
.listStyle(PlainListStyle())
.navigationTitle("Home")
.searchable(
text: $viewModel.searchQuery,
text: $viewModel.searchTerm,
placement: .toolbar
) {
if viewModel.searchQuery.isEmpty {
if viewModel.searchTerm.isEmpty {
Text("Inbox").searchCompletion("in:inbox ")
Text("All").searchCompletion("in:all ")
Text("Archived").searchCompletion("in:archive ")
Text("Files").searchCompletion("type:file ")
}
}
.onChange(of: viewModel.searchQuery) { _ in
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, isRefresh: true)

View File

@ -11,11 +11,15 @@ final class HomeFeedViewModel: ObservableObject {
/// Track progress updates to be committed when user navigates back to grid view
var uncommittedReadingProgressUpdates = [String: Double]()
/// Track label updates to be committed when user navigates back to grid view
var uncommittedLabelUpdates = [String: [FeedItemLabel]]()
@Published var items = [FeedItem]()
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: FeedItem?
@Published var searchQuery = ""
@Published var searchTerm = ""
@Published var selectedLabels = [FeedItemLabel]()
@Published var snoozePresented = false
@Published var itemToSnooze: FeedItem?
@Published var selectedLinkItem: FeedItem?
@ -68,7 +72,7 @@ final class HomeFeedViewModel: ObservableObject {
dataService.libraryItemsPublisher(
limit: 10,
sortDescending: true,
searchQuery: searchQuery.isEmpty ? nil : searchQuery,
searchQuery: searchQuery,
cursor: isRefresh ? nil : cursor
)
.sink(
@ -172,14 +176,18 @@ final class HomeFeedViewModel: ObservableObject {
.store(in: &subscriptions)
}
/// Update `FeedItem`s with the cached reading progress values so it can animate when the
/// Update `FeedItem`s with the cached reading progress and label values so it can animate when the
/// user navigates back to the grid view (and also avoid mutations of the grid items
/// that can cause the `NavigationView` to pop.
func commitProgressUpdates() {
func commitItemUpdates() {
for (key, value) in uncommittedReadingProgressUpdates {
updateProgress(itemID: key, progress: value)
}
for (key, value) in uncommittedLabelUpdates {
updateLabels(itemID: key, labels: value)
}
uncommittedReadingProgressUpdates = [:]
uncommittedLabelUpdates = [:]
}
private func updateProgress(itemID: String, progress: Double) {
@ -190,9 +198,31 @@ final class HomeFeedViewModel: ObservableObject {
}
func updateLabels(itemID: String, labels: [FeedItemLabel]) {
// If item is being being displayed then delay the state update of labels until
// user is no longer reading the item.
if selectedLinkItem != nil {
uncommittedLabelUpdates[itemID] = labels
return
}
guard let item = items.first(where: { $0.id == itemID }) else { return }
if let index = items.firstIndex(of: item) {
items[index].labels = labels
}
}
private var searchQuery: String? {
if searchTerm.isEmpty, selectedLabels.isEmpty {
return nil
}
var query = searchTerm
if !selectedLabels.isEmpty {
query.append(" label:")
query.append(selectedLabels.map(\.name).joined(separator: ","))
}
return query
}
}

View File

@ -4,7 +4,30 @@ import SwiftUI
import Views
struct ApplyLabelsView: View {
let item: FeedItem
enum Mode {
case item(FeedItem)
case list([FeedItemLabel])
var navTitle: String {
switch self {
case .item:
return "Assign Labels"
case .list:
return "Apply Label Filters"
}
}
var confirmButtonText: String {
switch self {
case .item:
return "Save"
case .list:
return "Apply"
}
}
}
let mode: Mode
let commitLabelChanges: ([FeedItemLabel]) -> Void
@EnvironmentObject var dataService: DataService
@ -15,10 +38,10 @@ struct ApplyLabelsView: View {
var innerBody: some View {
List {
Section(header: Text("Assigned Labels")) {
if viewModel.selectedLabelsForItemInContext.isEmpty {
if viewModel.selectedLabels.isEmpty {
Text("No labels are currently assigned.")
}
ForEach(viewModel.selectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in
ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
HStack {
TextChip(feedItemLabel: label)
Spacer()
@ -28,13 +51,13 @@ struct ApplyLabelsView: View {
viewModel.removeLabelFromItem(label)
}
},
label: { Image(systemName: "trash").foregroundColor(.appGrayTextContrast) }
label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) }
)
}
}
}
Section(header: Text("Available Labels")) {
ForEach(viewModel.unselectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in
ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
HStack {
TextChip(feedItemLabel: label)
Spacer()
@ -63,7 +86,7 @@ struct ApplyLabelsView: View {
.disabled(viewModel.isLoading)
}
}
.navigationTitle("Assign Labels")
.navigationTitle(mode.navTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -76,12 +99,18 @@ struct ApplyLabelsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in
commitLabelChanges(labels)
switch mode {
case let .item(feedItem):
viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
case .list:
commitLabelChanges(viewModel.selectedLabels)
presentationMode.wrappedValue.dismiss()
}
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
label: { Text(mode.confirmButtonText).foregroundColor(.appGrayTextContrast) }
)
}
}
@ -112,7 +141,12 @@ struct ApplyLabelsView: View {
}
}
.onAppear {
viewModel.loadLabels(dataService: dataService, item: item)
switch mode {
case let .item(feedItem):
viewModel.loadLabels(dataService: dataService, item: feedItem)
case let .list(labels):
viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
}
}
}
}

View File

@ -2,6 +2,7 @@ import Combine
import Models
import Services
import SwiftUI
import Utils
import Views
struct LabelsView: View {
@ -19,7 +20,7 @@ struct LabelsView: View {
Form {
innerBody
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
Button("Delete Label", role: .destructive) {
if let labelID = labelToRemoveID {
withAnimation {
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
@ -92,49 +93,141 @@ struct CreateLabelView: View {
@State private var newLabelName = ""
@State private var newLabelColor = Color.clear
var shouldDisableCreateButton: Bool {
viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear
}
let rows = [
GridItem(.fixed(60)),
GridItem(.fixed(60)),
GridItem(.fixed(70))
]
let swatches: [Color] = {
let webSwatches = webSwatchHexes.map { Color(hex: $0) ?? .clear }
var additionalSwatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled()
let firstSwatch = additionalSwatches.remove(at: 0)
return [firstSwatch] + webSwatches + additionalSwatches
}()
var body: some View {
NavigationView {
VStack(spacing: 16) {
VStack {
HStack {
if !newLabelName.isEmpty, newLabelColor != .clear {
TextChip(text: newLabelName, color: newLabelColor)
} else {
Text("Assign a name and color.").font(.appBody)
}
Spacer()
}
.padding(.bottom, 8)
TextField("Label Name", text: $newLabelName)
#if os(iOS)
.keyboardType(.alphabet)
#endif
.textFieldStyle(StandardTextFieldStyle())
ColorPicker(
newLabelColor == .clear ? "Select Color" : newLabelColor.description,
selection: $newLabelColor
)
Button(
action: {
viewModel.createLabel(
dataService: dataService,
name: newLabelName,
color: newLabelColor,
description: nil
)
},
label: { Text("Create") }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
.disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear)
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, alignment: .top, spacing: 20) {
ForEach(swatches, id: \.self) { swatch in
ZStack {
Circle()
.fill(swatch)
.frame(width: 50, height: 50)
.onTapGesture {
newLabelColor = swatch
}
.padding(10)
if newLabelColor == swatch {
Circle()
.stroke(swatch, lineWidth: 5)
.frame(width: 60, height: 60)
}
}
}
}
}
Spacer()
}
.padding()
.toolbar {
ToolbarItem(placement: .automatic) {
ToolbarItem(placement: .barLeading) {
Button(
action: { viewModel.showCreateEmailModal = false },
label: {
Image(systemName: "xmark")
.foregroundColor(.appGrayTextContrast)
}
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
}
ToolbarItem(placement: .barTrailing) {
Button(
action: {
viewModel.createLabel(
dataService: dataService,
name: newLabelName,
color: newLabelColor,
description: nil
)
},
label: { Text("Create").foregroundColor(.appGrayTextContrast) }
)
.opacity(shouldDisableCreateButton ? 0.2 : 1)
.disabled(shouldDisableCreateButton)
}
}
.navigationTitle("Create New Label")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
newLabelColor = swatches.first ?? .clear
}
}
}
}
private let webSwatchHexes = [
"#FF5D99",
"#7CFF7B",
"#FFD234",
"#7BE4FF",
"#CE88EF",
"#EF8C43"
]
private let swatchHexes = [
"#fff034",
"#efff34",
"#d1ff34",
"#b2ff34",
"#94ff34",
"#75ff34",
"#57ff34",
"#38ff34",
"#34ff4e",
"#34ff6d",
"#34ff8b",
"#34ffa9",
"#34ffc8",
"#34ffe6",
"#34f9ff",
"#34dbff",
"#34bcff",
"#349eff",
"#347fff",
"#3461ff",
"#3443ff",
"#4434ff",
"#6234ff",
"#8134ff",
"#9f34ff",
"#be34ff",
"#dc34ff",
"#fb34ff",
"#ff34e5",
"#ff34c7",
"#ff34a8",
"#ff348a",
"#ff346b"
]

View File

@ -7,14 +7,19 @@ import Views
final class LabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@Published var isLoading = false
@Published var selectedLabelsForItemInContext = [FeedItemLabel]()
@Published var unselectedLabelsForItemInContext = [FeedItemLabel]()
@Published var selectedLabels = [FeedItemLabel]()
@Published var unselectedLabels = [FeedItemLabel]()
@Published var labels = [FeedItemLabel]()
@Published var showCreateEmailModal = false
var subscriptions = Set<AnyCancellable>()
func loadLabels(dataService: DataService, item: FeedItem?) {
/// Loads initial set of labels when a edit labels list is displayed
/// - Parameters:
/// - dataService: `DataService` reference
/// - item: Optional `FeedItem` for applying labels to a single item
/// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items
func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) {
guard !hasLoadedInitialLabels else { return }
isLoading = true
@ -25,11 +30,17 @@ final class LabelsViewModel: ObservableObject {
self?.labels = allLabels
self?.hasLoadedInitialLabels = true
if let item = item {
self?.selectedLabelsForItemInContext = item.labels
self?.unselectedLabelsForItemInContext = allLabels.filter { label in
self?.selectedLabels = item.labels
self?.unselectedLabels = allLabels.filter { label in
!item.labels.contains(where: { $0.id == label.id })
}
}
if let initiallySelectedLabels = initiallySelectedLabels {
self?.selectedLabels = initiallySelectedLabels
self?.unselectedLabels = allLabels.filter { label in
!initiallySelectedLabels.contains(where: { $0.id == label.id })
}
}
}
)
.store(in: &subscriptions)
@ -49,7 +60,7 @@ final class LabelsViewModel: ObservableObject {
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels.insert(result, at: 0)
self?.unselectedLabelsForItemInContext.insert(result, at: 0)
self?.unselectedLabels.insert(result, at: 0)
self?.showCreateEmailModal = false
}
)
@ -73,7 +84,7 @@ final class LabelsViewModel: ObservableObject {
func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) {
isLoading = true
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabelsForItemInContext.map(\.id)).sink(
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
@ -83,12 +94,12 @@ final class LabelsViewModel: ObservableObject {
}
func addLabelToItem(_ label: FeedItemLabel) {
selectedLabelsForItemInContext.insert(label, at: 0)
unselectedLabelsForItemInContext.removeAll { $0.id == label.id }
selectedLabels.insert(label, at: 0)
unselectedLabels.removeAll { $0.id == label.id }
}
func removeLabelFromItem(_ label: FeedItemLabel) {
unselectedLabelsForItemInContext.insert(label, at: 0)
selectedLabelsForItemInContext.removeAll { $0.id == label.id }
unselectedLabels.insert(label, at: 0)
selectedLabels.removeAll { $0.id == label.id }
}
}

View File

@ -132,6 +132,10 @@ import WebKit
Menu(
content: {
Group {
Button(
action: { homeFeedViewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(
action: {
homeFeedViewModel.setLinkArchived(

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ public extension DataService {
let mutation = Selection.Mutation {
try $0.setLabels(
input: InputObjects.SetLabelsInput(
linkId: itemID,
pageId: itemID,
labelIds: labelIDs
),
selection: selection

View File

@ -22,6 +22,7 @@ public extension Color {
static var systemBackground: Color { Color(.systemBackground) }
static var systemPlaceholder: Color { Color(.placeholderText) }
static var secondarySystemGroupedBackground: Color { Color(.secondarySystemGroupedBackground) }
static var systemGray6: Color { Color(.systemGray6) }
static var systemLabel: Color {
if #available(iOS 15.0, *) {
return Color(uiColor: .label)
@ -34,6 +35,7 @@ public extension Color {
static var systemBackground: Color { Color(.windowBackgroundColor) }
static var systemPlaceholder: Color { Color(.placeholderTextColor) }
static var systemLabel: Color { Color(.labelColor) }
static var systemGray6: Color { Color(NSColor.systemGray) }
// Just for compilation. secondarySystemGroupedBackground shouldn't be used on macOS
static var secondarySystemGroupedBackground: Color { Color(.windowBackgroundColor) }

View File

@ -30,3 +30,84 @@ public struct TextChip: View {
.cornerRadius(cornerRadius)
}
}
public struct TextChipButton: View {
public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton {
TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap)
}
public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton {
TextChipButton(title: title, color: .appButtonBackground, actionType: .add, onTap: onTap)
}
public static func makeRemovableLabelButton(
feedItemLabel: FeedItemLabel,
onTap: @escaping () -> Void
) -> TextChipButton {
TextChipButton(
title: feedItemLabel.name,
color: Color(hex: feedItemLabel.color) ?? .appButtonBackground,
actionType: .remove,
onTap: onTap
)
}
public enum ActionType {
case remove
case add
case show
var systemIconName: String {
switch self {
case .remove:
return "xmark"
case .add:
return "plus"
case .show:
return "chevron.down"
}
}
}
init(title: String, color: Color, actionType: ActionType, onTap: @escaping () -> Void) {
self.text = title
self.color = color
self.onTap = onTap
self.actionType = actionType
self.foregroundColor = {
if actionType == .show {
return .appGrayText
}
return color.isDark ? .white : .black
}()
}
let text: String
let color: Color
let onTap: () -> Void
let actionType: ActionType
let cornerRadius = 20.0
let foregroundColor: Color
public var body: some View {
Button(action: onTap) {
VStack(spacing: 0) {
HStack {
Text(text)
.padding(.leading, 3)
Image(systemName: actionType.systemIconName)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.font(.appFootnote)
.foregroundColor(foregroundColor)
.lineLimit(1)
.background(color)
.cornerRadius(cornerRadius)
Color.clear.contentShape(Rectangle()).frame(height: 15)
}
.contentShape(Rectangle())
}
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
public extension ToolbarItemPlacement {
static var barLeading: ToolbarItemPlacement {
#if os(iOS)
.navigationBarLeading
#else
.automatic
#endif
}
static var barTrailing: ToolbarItemPlacement {
#if os(iOS)
.navigationBarTrailing
#else
.automatic
#endif
}
}

View File

@ -4,10 +4,12 @@ scalars:
SanitizedString_undefined_15: String
SanitizedString_undefined_40: String
SanitizedString_undefined_50: String
SanitizedString_undefined_64: String
SanitizedString_undefined_95: String
SanitizedString_undefined_100: String
SanitizedString_undefined_300: String
SanitizedString_undefined_400: String
SanitizedString_undefined_2000: String
SanitizedString_undefined_4000: String
SanitizedString_undefined_8000: String
SanitizedString_undefined_undefined: String
SanitizedString_undefined_undefined: String