From 0fa9526924954d74f78d2e8c5ffbb365b8cee7eb Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Fri, 8 Apr 2022 12:56:08 -0700 Subject: [PATCH 01/19] use searchTerm and selected labels to compute searchQuery --- .../App/Views/Home/HomeFeedViewIOS.swift | 6 +++--- .../App/Views/Home/HomeFeedViewMac.swift | 6 +++--- .../App/Views/Home/HomeFeedViewModel.swift | 19 +++++++++++++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 7c5e60d82..e6c0e494b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -23,17 +23,17 @@ import Views viewModel.loadItems(dataService: dataService, isRefresh: true) } .searchable( - text: $viewModel.searchQuery, + text: $viewModel.searchTerm, placement: .sidebar ) { - 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) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index 91cad42e8..640f6fdc5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 7a9af7d98..61829eafd 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -15,7 +15,8 @@ final class HomeFeedViewModel: ObservableObject { @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 +69,7 @@ final class HomeFeedViewModel: ObservableObject { dataService.libraryItemsPublisher( limit: 10, sortDescending: true, - searchQuery: searchQuery.isEmpty ? nil : searchQuery, + searchQuery: searchQuery, cursor: isRefresh ? nil : cursor ) .sink( @@ -195,4 +196,18 @@ final class HomeFeedViewModel: ObservableObject { items[index].labels = labels } } + + private var searchQuery: String? { + if searchTerm.isEmpty, selectedLabels.isEmpty { + return nil + } + + var query = searchTerm + + for label in selectedLabels { + query.append(" label:\(label.name)") + } + + return query + } } From df291ecbab34f83d019d76fbea0140f1d68c472b Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sun, 10 Apr 2022 19:25:06 -0700 Subject: [PATCH 02/19] add text chip buttons to represent labels used as filters --- .../App/Views/Home/HomeFeedViewIOS.swift | 91 ++++++++++++------- .../OmnivoreKit/Sources/Views/TextChip.swift | 68 ++++++++++++++ 2 files changed, 127 insertions(+), 32 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index e6c0e494b..bf5c6b885 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -24,7 +24,7 @@ import Views } .searchable( text: $viewModel.searchTerm, - placement: .sidebar + placement: .navigationBarDrawer ) { if viewModel.searchTerm.isEmpty { Text("Inbox").searchCompletion("in:inbox ") @@ -71,6 +71,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 { @@ -106,48 +107,74 @@ import Views struct HomeFeedView: View { @EnvironmentObject var dataService: DataService - @Binding var prefersListLayout: Bool - + @State private var showLabelsSheet = false @ObservedObject var viewModel: HomeFeedViewModel + // TODO: remove stub + let demoFilterChips = [ + FeedItemLabel(id: "1", name: "Inbox", color: "#039466", createdAt: nil, description: nil), + FeedItemLabel(id: "2", name: "NotInbox", color: "#039466", createdAt: nil, description: nil), + FeedItemLabel(id: "3", name: "Atari", color: "#039466", createdAt: nil, description: nil), + FeedItemLabel(id: "4", name: "iOS", color: "#039466", createdAt: nil, description: nil) + ] + 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 { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + TextChipButton.makeAddLabelButton { + showLabelsSheet = true + } + ForEach(demoFilterChips, id: \.self) { label in + TextChipButton.makeRemovableLabelButton(feedItemLabel: label) { + print("tapped label named \(label.name)") + } + } + Spacer() + } + .padding(.horizontal) + .sheet(isPresented: $showLabelsSheet) { + Text("select labels stub") + } + } + 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") - } - ) - } - } - } + } } } } diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index cb55573e8..3fcd16e12 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -30,3 +30,71 @@ public struct TextChip: View { .cornerRadius(cornerRadius) } } + +public struct TextChipButton: View { + public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton { + TextChipButton(title: "Label Filter", color: .appYellow48, actionType: .add, 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 + } + + let text: String + let color: Color + let onTap: () -> Void + let actionType: ActionType + let cornerRadius = 20.0 + + public var body: some View { + Button(action: onTap) { + HStack { + Text(text) + Image(systemName: actionType.systemIconName) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .font(.appFootnote) + .foregroundColor(color.isDark ? .white : .black) + .lineLimit(1) + .background(color) + .cornerRadius(cornerRadius) + } + } +} From bd652dc3edfd49e94696c9baa82c895bd2792434 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sun, 10 Apr 2022 20:42:52 -0700 Subject: [PATCH 03/19] rename selectedLabelsForItemInContext to selectedLabels --- .../App/Views/Labels/ApplyLabelsView.swift | 6 ++-- .../App/Views/Labels/LabelsViewModel.swift | 34 +++++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 1c1348c94..0d46e15b7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -15,10 +15,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() @@ -34,7 +34,7 @@ struct ApplyLabelsView: View { } } 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() diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index b38109c72..e8020c684 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -7,14 +7,20 @@ 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() - 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 `[FeedItem]` for filtering a list of items + func loadLabels(dataService: DataService, item: FeedItem?, initiallySelectedLabels: [FeedItem]?) { guard !hasLoadedInitialLabels else { return } isLoading = true @@ -25,11 +31,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 +61,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 +85,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 +95,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 } } } From c8ad2f47a2c2e137cc582ed8bb8e1f8f0d121a4b Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sun, 10 Apr 2022 21:09:42 -0700 Subject: [PATCH 04/19] adapt assign labels view to work on a list --- .../App/Views/Home/HomeFeedViewIOS.swift | 23 ++++------ .../App/Views/Labels/ApplyLabelsView.swift | 46 ++++++++++++++++--- .../App/Views/Labels/LabelsViewModel.swift | 5 +- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index bf5c6b885..611979fc9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -38,11 +38,14 @@ import Views // 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) } } @@ -111,14 +114,6 @@ import Views @State private var showLabelsSheet = false @ObservedObject var viewModel: HomeFeedViewModel - // TODO: remove stub - let demoFilterChips = [ - FeedItemLabel(id: "1", name: "Inbox", color: "#039466", createdAt: nil, description: nil), - FeedItemLabel(id: "2", name: "NotInbox", color: "#039466", createdAt: nil, description: nil), - FeedItemLabel(id: "3", name: "Atari", color: "#039466", createdAt: nil, description: nil), - FeedItemLabel(id: "4", name: "iOS", color: "#039466", createdAt: nil, description: nil) - ] - var body: some View { VStack { ScrollView(.horizontal, showsIndicators: false) { @@ -126,16 +121,18 @@ import Views TextChipButton.makeAddLabelButton { showLabelsSheet = true } - ForEach(demoFilterChips, id: \.self) { label in + ForEach(viewModel.selectedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label) { - print("tapped label named \(label.name)") + viewModel.selectedLabels.removeAll { $0.id == label.id } } } Spacer() } .padding(.horizontal) .sheet(isPresented: $showLabelsSheet) { - Text("select labels stub") + ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in + viewModel.selectedLabels = labels + } } } if prefersListLayout { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 0d46e15b7..3b0c80a85 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -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 @@ -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) + } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index e8020c684..35159e8ca 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -14,13 +14,12 @@ final class LabelsViewModel: ObservableObject { var subscriptions = Set() - /// 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 `[FeedItem]` for filtering a list of items - func loadLabels(dataService: DataService, item: FeedItem?, initiallySelectedLabels: [FeedItem]?) { + /// - 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 From be68971a0323952701ef8634a7c1ded5a7be9b49 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sun, 10 Apr 2022 21:13:28 -0700 Subject: [PATCH 05/19] use xmark for remove label button --- .../OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 3b0c80a85..8cf320384 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -51,7 +51,7 @@ struct ApplyLabelsView: View { viewModel.removeLabelFromItem(label) } }, - label: { Image(systemName: "trash").foregroundColor(.appGrayTextContrast) } + label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) } ) } } From d68c8e5e337bb50f80cb20fa38fac93838079518 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sun, 10 Apr 2022 21:38:36 -0700 Subject: [PATCH 06/19] update search query to handle multiple labels --- .../Sources/App/Views/Home/HomeFeedViewModel.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 61829eafd..763615607 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -204,8 +204,9 @@ final class HomeFeedViewModel: ObservableObject { var query = searchTerm - for label in selectedLabels { - query.append(" label:\(label.name)") + if !selectedLabels.isEmpty { + query.append(" label:") + query.append(selectedLabels.map(\.name).joined(separator: ",")) } return query From 6c96b256d1603d89c6da9ef316b219a46c18e9d9 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Tue, 12 Apr 2022 12:30:33 -0700 Subject: [PATCH 07/19] move create label submit button into navbar --- .../Sources/App/Views/Labels/LabelsView.swift | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 627305438..39c0049b6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -92,6 +92,10 @@ struct CreateLabelView: View { @State private var newLabelName = "" @State private var newLabelColor = Color.clear + var shouldDisableCreateButton: Bool { + viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear + } + var body: some View { NavigationView { VStack(spacing: 16) { @@ -104,32 +108,24 @@ struct CreateLabelView: View { 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) Spacer() } .padding() .toolbar { - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .navigationBarLeading) { Button( action: { viewModel.showCreateEmailModal = false }, - label: { - Image(systemName: "xmark") - .foregroundColor(.appGrayTextContrast) - } + label: { Text("Cancel").foregroundColor(.appGrayTextContrast) } ) } + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: {}, + label: { Text("Create").foregroundColor(.appGrayTextContrast) } + ) + .opacity(shouldDisableCreateButton ? 0.2 : 1) + .disabled(shouldDisableCreateButton) + } } .navigationTitle("Create New Label") #if os(iOS) From 060313ab31f3f2ded2de811342714aea955d17af Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Tue, 12 Apr 2022 15:05:29 -0700 Subject: [PATCH 08/19] use hgrid to display label color selections --- .../Sources/App/Views/Labels/LabelsView.swift | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 39c0049b6..903726be1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -19,7 +19,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) @@ -96,18 +96,53 @@ struct CreateLabelView: View { viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear } + let rows = [ + GridItem(.fixed(60)), + GridItem(.fixed(60)), + GridItem(.fixed(60)) + ] + + let swatches = (0 ... 200).map { _ in Color.random } + 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.") + } + Spacer() + } + TextField("Label Name", text: $newLabelName) #if os(iOS) .keyboardType(.alphabet) #endif .textFieldStyle(StandardTextFieldStyle()) - ColorPicker( - newLabelColor == .clear ? "Select Color" : newLabelColor.description, - selection: $newLabelColor - ) + + 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() @@ -120,7 +155,14 @@ struct CreateLabelView: View { } ToolbarItem(placement: .navigationBarTrailing) { Button( - action: {}, + action: { + viewModel.createLabel( + dataService: dataService, + name: newLabelName, + color: newLabelColor, + description: nil + ) + }, label: { Text("Create").foregroundColor(.appGrayTextContrast) } ) .opacity(shouldDisableCreateButton ? 0.2 : 1) @@ -134,3 +176,9 @@ struct CreateLabelView: View { } } } + +extension Color { + static var random: Color { + Color(hue: .random(in: 0 ... 1), saturation: .random(in: 0.2 ... 0.8), brightness: .random(in: 0.5 ... 0.8)) + } +} From 751095b9834fb489c331b70f82ceb480aa2fc91b Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Tue, 12 Apr 2022 15:28:14 -0700 Subject: [PATCH 09/19] add padding below text chip preview --- apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 903726be1..fd2ab65c0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -115,6 +115,7 @@ struct CreateLabelView: View { } Spacer() } + .padding(.bottom, 8) TextField("Label Name", text: $newLabelName) #if os(iOS) From 57b8b763b071cd7104e95cbfb68be8f6499fd969 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Tue, 12 Apr 2022 16:09:53 -0700 Subject: [PATCH 10/19] run swift gql gen --- .../Services/DataService/GQLSchema.swift | 1425 ++++++++++++++++- .../UpdateArticleLabelsPublisher.swift | 2 +- apple/swiftgraphql.yml | 4 +- 3 files changed, 1401 insertions(+), 30 deletions(-) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 6f50d6b8b..162b121e0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -5177,7 +5177,6 @@ extension Objects { struct Highlight { let __typename: TypeName = .highlight let annotation: [String: String] - let article: [String: Objects.Article] let createdAt: [String: DateTime] let createdByMe: [String: Bool] let id: [String: String] @@ -5214,10 +5213,6 @@ extension Objects.Highlight: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } - case "article": - if let value = try container.decode(Objects.Article?.self, forKey: codingKey) { - map.set(key: field, hash: alias, value: value as Any) - } case "createdAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -5281,7 +5276,6 @@ extension Objects.Highlight: Decodable { } annotation = map["annotation"] - article = map["article"] createdAt = map["createdAt"] createdByMe = map["createdByMe"] id = map["id"] @@ -5354,25 +5348,6 @@ extension Fields where TypeLock == Objects.Highlight { } } - func article(selection: Selection) throws -> Type { - let field = GraphQLField.composite( - name: "article", - arguments: [], - selection: selection.selection - ) - select(field) - - switch response { - case let .decoding(data): - if let data = data.article[field.alias!] { - return try selection.decode(data: data) - } - throw HttpError.badpayload - case .mocking: - return selection.mock() - } - } - func quote() throws -> String { let field = GraphQLField.leaf( name: "quote", @@ -11260,6 +11235,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteLabelError = Selection } +extension Objects { + struct UpdateLabelSuccess { + let __typename: TypeName = .updateLabelSuccess + let label: [String: Objects.Label] + + enum TypeName: String, Codable { + case updateLabelSuccess = "UpdateLabelSuccess" + } + } +} + +extension Objects.UpdateLabelSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "label": + if let value = try container.decode(Objects.Label?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + label = map["label"] + } +} + +extension Fields where TypeLock == Objects.UpdateLabelSuccess { + func label(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "label", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.label[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelSuccess = Selection +} + +extension Objects { + struct UpdateLabelError { + let __typename: TypeName = .updateLabelError + let errorCodes: [String: [Enums.UpdateLabelErrorCode]] + + enum TypeName: String, Codable { + case updateLabelError = "UpdateLabelError" + } + } +} + +extension Objects.UpdateLabelError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateLabelErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.UpdateLabelError { + func errorCodes() throws -> [Enums.UpdateLabelErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelError = Selection +} + extension Objects { struct SignupSuccess { let __typename: TypeName = .signupSuccess @@ -11522,6 +11628,910 @@ extension Selection where TypeLock == Never, Type == Never { typealias SetLabelsError = Selection } +extension Objects { + struct GenerateApiKeySuccess { + let __typename: TypeName = .generateApiKeySuccess + let apiKey: [String: String] + + enum TypeName: String, Codable { + case generateApiKeySuccess = "GenerateApiKeySuccess" + } + } +} + +extension Objects.GenerateApiKeySuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "apiKey": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + apiKey = map["apiKey"] + } +} + +extension Fields where TypeLock == Objects.GenerateApiKeySuccess { + func apiKey() throws -> String { + let field = GraphQLField.leaf( + name: "apiKey", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.apiKey[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeySuccess = Selection +} + +extension Objects { + struct GenerateApiKeyError { + let __typename: TypeName = .generateApiKeyError + let errorCodes: [String: [Enums.GenerateApiKeyErrorCode]] + + enum TypeName: String, Codable { + case generateApiKeyError = "GenerateApiKeyError" + } + } +} + +extension Objects.GenerateApiKeyError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GenerateApiKeyErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GenerateApiKeyError { + func errorCodes() throws -> [Enums.GenerateApiKeyErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeyError = Selection +} + +extension Objects { + struct SearchItem { + let __typename: TypeName = .searchItem + let annotation: [String: String] + let author: [String: String] + let contentReader: [String: Enums.ContentReader] + let createdAt: [String: DateTime] + let description: [String: String] + let id: [String: String] + let image: [String: String] + let isArchived: [String: Bool] + let labels: [String: [Objects.Label]] + let originalArticleUrl: [String: String] + let ownedByViewer: [String: Bool] + let pageId: [String: String] + let pageType: [String: Enums.PageType] + let publishedAt: [String: DateTime] + let quote: [String: String] + let readingProgressAnchorIndex: [String: Int] + let readingProgressPercent: [String: Double] + let shortId: [String: String] + let slug: [String: String] + let title: [String: String] + let uploadFileId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case searchItem = "SearchItem" + } + } +} + +extension Objects.SearchItem: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "annotation": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "author": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "contentReader": + if let value = try container.decode(Enums.ContentReader?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "isArchived": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "labels": + if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "originalArticleUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "ownedByViewer": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageType": + if let value = try container.decode(Enums.PageType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "publishedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "quote": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "readingProgressAnchorIndex": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "readingProgressPercent": + if let value = try container.decode(Double?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "shortId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "slug": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "uploadFileId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + annotation = map["annotation"] + author = map["author"] + contentReader = map["contentReader"] + createdAt = map["createdAt"] + description = map["description"] + id = map["id"] + image = map["image"] + isArchived = map["isArchived"] + labels = map["labels"] + originalArticleUrl = map["originalArticleUrl"] + ownedByViewer = map["ownedByViewer"] + pageId = map["pageId"] + pageType = map["pageType"] + publishedAt = map["publishedAt"] + quote = map["quote"] + readingProgressAnchorIndex = map["readingProgressAnchorIndex"] + readingProgressPercent = map["readingProgressPercent"] + shortId = map["shortId"] + slug = map["slug"] + title = map["title"] + uploadFileId = map["uploadFileId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.SearchItem { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func slug() throws -> String { + let field = GraphQLField.leaf( + name: "slug", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.slug[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func pageType() throws -> Enums.PageType { + let field = GraphQLField.leaf( + name: "pageType", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageType[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.PageType.allCases.first! + } + } + + func contentReader() throws -> Enums.ContentReader { + let field = GraphQLField.leaf( + name: "contentReader", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.contentReader[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.ContentReader.allCases.first! + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func isArchived() throws -> Bool { + let field = GraphQLField.leaf( + name: "isArchived", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.isArchived[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Bool.mockValue + } + } + + func readingProgressPercent() throws -> Double? { + let field = GraphQLField.leaf( + name: "readingProgressPercent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.readingProgressPercent[field.alias!] + case .mocking: + return nil + } + } + + func readingProgressAnchorIndex() throws -> Int? { + let field = GraphQLField.leaf( + name: "readingProgressAnchorIndex", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.readingProgressAnchorIndex[field.alias!] + case .mocking: + return nil + } + } + + func author() throws -> String? { + let field = GraphQLField.leaf( + name: "author", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.author[field.alias!] + case .mocking: + return nil + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func publishedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "publishedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.publishedAt[field.alias!] + case .mocking: + return nil + } + } + + func ownedByViewer() throws -> Bool? { + let field = GraphQLField.leaf( + name: "ownedByViewer", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.ownedByViewer[field.alias!] + case .mocking: + return nil + } + } + + func originalArticleUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "originalArticleUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.originalArticleUrl[field.alias!] + case .mocking: + return nil + } + } + + func uploadFileId() throws -> String? { + let field = GraphQLField.leaf( + name: "uploadFileId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.uploadFileId[field.alias!] + case .mocking: + return nil + } + } + + func pageId() throws -> String? { + let field = GraphQLField.leaf( + name: "pageId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.pageId[field.alias!] + case .mocking: + return nil + } + } + + func shortId() throws -> String? { + let field = GraphQLField.leaf( + name: "shortId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.shortId[field.alias!] + case .mocking: + return nil + } + } + + func quote() throws -> String? { + let field = GraphQLField.leaf( + name: "quote", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.quote[field.alias!] + case .mocking: + return nil + } + } + + func annotation() throws -> String? { + let field = GraphQLField.leaf( + name: "annotation", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.annotation[field.alias!] + case .mocking: + return nil + } + } + + func labels(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "labels", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.labels[field.alias!]) + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchItem = Selection +} + +extension Objects { + struct SearchItemEdge { + let __typename: TypeName = .searchItemEdge + let cursor: [String: String] + let node: [String: Objects.SearchItem] + + enum TypeName: String, Codable { + case searchItemEdge = "SearchItemEdge" + } + } +} + +extension Objects.SearchItemEdge: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "cursor": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "node": + if let value = try container.decode(Objects.SearchItem?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + cursor = map["cursor"] + node = map["node"] + } +} + +extension Fields where TypeLock == Objects.SearchItemEdge { + func cursor() throws -> String { + let field = GraphQLField.leaf( + name: "cursor", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.cursor[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func node(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "node", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.node[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchItemEdge = Selection +} + +extension Objects { + struct SearchSuccess { + let __typename: TypeName = .searchSuccess + let edges: [String: [Objects.SearchItemEdge]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case searchSuccess = "SearchSuccess" + } + } +} + +extension Objects.SearchSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.SearchItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + edges = map["edges"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.SearchSuccess { + func edges(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "edges", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.edges[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchSuccess = Selection +} + +extension Objects { + struct SearchError { + let __typename: TypeName = .searchError + let errorCodes: [String: [Enums.SearchErrorCode]] + + enum TypeName: String, Codable { + case searchError = "SearchError" + } + } +} + +extension Objects.SearchError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SearchErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.SearchError { + func errorCodes() throws -> [Enums.SearchErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchError = Selection +} + extension Objects { struct Mutation { let __typename: TypeName = .mutation @@ -11539,6 +12549,7 @@ extension Objects { let deleteNewsletterEmail: [String: Unions.DeleteNewsletterEmailResult] let deleteReaction: [String: Unions.DeleteReactionResult] let deleteReminder: [String: Unions.DeleteReminderResult] + let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] let googleSignup: [String: Unions.GoogleSignupResult] let logOut: [String: Unions.LogOutResult] @@ -11560,6 +12571,7 @@ extension Objects { let signup: [String: Unions.SignupResult] let updateHighlight: [String: Unions.UpdateHighlightResult] let updateHighlightReply: [String: Unions.UpdateHighlightReplyResult] + let updateLabel: [String: Unions.UpdateLabelResult] let updateLinkShareInfo: [String: Unions.UpdateLinkShareInfoResult] let updateReminder: [String: Unions.UpdateReminderResult] let updateSharedComment: [String: Unions.UpdateSharedCommentResult] @@ -11641,6 +12653,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "generateApiKey": + if let value = try container.decode(Unions.GenerateApiKeyResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "googleLogin": if let value = try container.decode(Unions.LoginResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11725,6 +12741,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.UpdateHighlightReplyResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "updateLabel": + if let value = try container.decode(Unions.UpdateLabelResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "updateLinkShareInfo": if let value = try container.decode(Unions.UpdateLinkShareInfoResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11773,6 +12793,7 @@ extension Objects.Mutation: Decodable { deleteNewsletterEmail = map["deleteNewsletterEmail"] deleteReaction = map["deleteReaction"] deleteReminder = map["deleteReminder"] + generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] googleSignup = map["googleSignup"] logOut = map["logOut"] @@ -11794,6 +12815,7 @@ extension Objects.Mutation: Decodable { signup = map["signup"] updateHighlight = map["updateHighlight"] updateHighlightReply = map["updateHighlightReply"] + updateLabel = map["updateLabel"] updateLinkShareInfo = map["updateLinkShareInfo"] updateReminder = map["updateReminder"] updateSharedComment = map["updateSharedComment"] @@ -12507,6 +13529,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func updateLabel(input: InputObjects.UpdateLabelInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "updateLabel", + arguments: [Argument(name: "input", type: "UpdateLabelInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updateLabel[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteLabel(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteLabel", @@ -12582,6 +13623,25 @@ extension Fields where TypeLock == Objects.Mutation { return selection.mock() } } + + func generateApiKey(scope: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "generateApiKey", + arguments: [Argument(name: "scope", type: "String", value: scope)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.generateApiKey[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -12603,6 +13663,7 @@ extension Objects { let me: [String: Objects.User] let newsletterEmails: [String: Unions.NewsletterEmailsResult] let reminder: [String: Unions.ReminderResult] + let search: [String: Unions.SearchResult] let sharedArticle: [String: Unions.SharedArticleResult] let user: [String: Unions.UserResult] let users: [String: Unions.UsersResult] @@ -12674,6 +13735,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.ReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "search": + if let value = try container.decode(Unions.SearchResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "sharedArticle": if let value = try container.decode(Unions.SharedArticleResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -12712,6 +13777,7 @@ extension Objects.Query: Decodable { me = map["me"] newsletterEmails = map["newsletterEmails"] reminder = map["reminder"] + search = map["search"] sharedArticle = map["sharedArticle"] user = map["user"] users = map["users"] @@ -13015,6 +14081,25 @@ extension Fields where TypeLock == Objects.Query { return selection.mock() } } + + func search(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), query: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "search", + arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "first", type: "Int", value: first), Argument(name: "query", type: "String", value: query)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.search[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -16658,6 +17743,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteLabelResult = Selection } +extension Unions { + struct UpdateLabelResult { + let __typename: TypeName + let errorCodes: [String: [Enums.UpdateLabelErrorCode]] + let label: [String: Objects.Label] + + enum TypeName: String, Codable { + case updateLabelSuccess = "UpdateLabelSuccess" + case updateLabelError = "UpdateLabelError" + } + } +} + +extension Unions.UpdateLabelResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateLabelErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "label": + if let value = try container.decode(Objects.Label?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + label = map["label"] + } +} + +extension Fields where TypeLock == Unions.UpdateLabelResult { + func on(updateLabelSuccess: Selection, updateLabelError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "UpdateLabelSuccess", selection: updateLabelSuccess.selection), GraphQLField.fragment(type: "UpdateLabelError", selection: updateLabelError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .updateLabelSuccess: + let data = Objects.UpdateLabelSuccess(label: data.label) + return try updateLabelSuccess.decode(data: data) + case .updateLabelError: + let data = Objects.UpdateLabelError(errorCodes: data.errorCodes) + return try updateLabelError.decode(data: data) + } + case .mocking: + return updateLabelSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelResult = Selection +} + extension Unions { struct SignupResult { let __typename: TypeName @@ -16806,6 +17965,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias SetLabelsResult = Selection } +extension Unions { + struct GenerateApiKeyResult { + let __typename: TypeName + let apiKey: [String: String] + let errorCodes: [String: [Enums.GenerateApiKeyErrorCode]] + + enum TypeName: String, Codable { + case generateApiKeySuccess = "GenerateApiKeySuccess" + case generateApiKeyError = "GenerateApiKeyError" + } + } +} + +extension Unions.GenerateApiKeyResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "apiKey": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GenerateApiKeyErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + apiKey = map["apiKey"] + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Unions.GenerateApiKeyResult { + func on(generateApiKeySuccess: Selection, generateApiKeyError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GenerateApiKeySuccess", selection: generateApiKeySuccess.selection), GraphQLField.fragment(type: "GenerateApiKeyError", selection: generateApiKeyError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .generateApiKeySuccess: + let data = Objects.GenerateApiKeySuccess(apiKey: data.apiKey) + return try generateApiKeySuccess.decode(data: data) + case .generateApiKeyError: + let data = Objects.GenerateApiKeyError(errorCodes: data.errorCodes) + return try generateApiKeyError.decode(data: data) + } + case .mocking: + return generateApiKeySuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeyResult = Selection +} + +extension Unions { + struct SearchResult { + let __typename: TypeName + let edges: [String: [Objects.SearchItemEdge]] + let errorCodes: [String: [Enums.SearchErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case searchSuccess = "SearchSuccess" + case searchError = "SearchError" + } + } +} + +extension Unions.SearchResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.SearchItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.SearchErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + edges = map["edges"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.SearchResult { + func on(searchSuccess: Selection, searchError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SearchSuccess", selection: searchSuccess.selection), GraphQLField.fragment(type: "SearchError", selection: searchError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .searchSuccess: + let data = Objects.SearchSuccess(edges: data.edges, pageInfo: data.pageInfo) + return try searchSuccess.decode(data: data) + case .searchError: + let data = Objects.SearchError(errorCodes: data.errorCodes) + return try searchError.decode(data: data) + } + case .mocking: + return searchSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchResult = Selection +} + // MARK: - Enums enum Enums {} @@ -16957,6 +18270,8 @@ extension Enums { case website = "WEBSITE" + case highlights = "HIGHLIGHTS" + case unknown = "UNKNOWN" } } @@ -17421,6 +18736,19 @@ extension Enums { } } +extension Enums { + /// UpdateLabelErrorCode + enum UpdateLabelErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case forbidden = "FORBIDDEN" + } +} + extension Enums { /// SetLabelsErrorCode enum SetLabelsErrorCode: String, CaseIterable, Codable { @@ -17432,6 +18760,20 @@ extension Enums { } } +extension Enums { + /// GenerateApiKeyErrorCode + enum GenerateApiKeyErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + } +} + +extension Enums { + /// SearchErrorCode + enum SearchErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + } +} + // MARK: - Input Objects enum InputObjects {} @@ -18293,6 +19635,33 @@ extension InputObjects { } } +extension InputObjects { + struct UpdateLabelInput: Encodable, Hashable { + var labelId: String + + var color: String + + var description: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(labelId, forKey: .labelId) + try container.encode(color, forKey: .color) + if description.hasValue { try container.encode(description, forKey: .description) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case labelId + case color + case description + case name + } + } +} + extension InputObjects { struct LoginInput: Encodable, Hashable { var password: String @@ -18349,18 +19718,18 @@ extension InputObjects { extension InputObjects { struct SetLabelsInput: Encodable, Hashable { - var linkId: String + var pageId: String var labelIds: [String] func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(linkId, forKey: .linkId) + try container.encode(pageId, forKey: .pageId) try container.encode(labelIds, forKey: .labelIds) } enum CodingKeys: String, CodingKey { - case linkId + case pageId case labelIds } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift index fce103d6a..0809b693a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -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 diff --git a/apple/swiftgraphql.yml b/apple/swiftgraphql.yml index 9a2b8bd2b..dd96fa3fc 100644 --- a/apple/swiftgraphql.yml +++ b/apple/swiftgraphql.yml @@ -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 \ No newline at end of file + SanitizedString_undefined_undefined: String From 567f3dedad5d2fd5040edb16fd69fc7f3a294152 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 08:49:45 -0700 Subject: [PATCH 11/19] use fixed list of colors for label creation --- .../Sources/App/Views/Labels/LabelsView.swift | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index fd2ab65c0..b2af72baa 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -99,10 +99,10 @@ struct CreateLabelView: View { let rows = [ GridItem(.fixed(60)), GridItem(.fixed(60)), - GridItem(.fixed(60)) + GridItem(.fixed(70)) ] - let swatches = (0 ... 200).map { _ in Color.random } + let swatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled() var body: some View { NavigationView { @@ -111,7 +111,7 @@ struct CreateLabelView: View { if !newLabelName.isEmpty, newLabelColor != .clear { TextChip(text: newLabelName, color: newLabelColor) } else { - Text("Assign a name and color.") + Text("Assign a name and color.").font(.appBody) } Spacer() } @@ -178,8 +178,38 @@ struct CreateLabelView: View { } } -extension Color { - static var random: Color { - Color(hue: .random(in: 0 ... 1), saturation: .random(in: 0.2 ... 0.8), brightness: .random(in: 0.5 ... 0.8)) - } -} +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" +] From c5233d15594d3455c7dea91d62db57391e479c04 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 10:04:29 -0700 Subject: [PATCH 12/19] adjust add label button styling --- .../Sources/App/Views/Home/HomeFeedViewIOS.swift | 1 + apple/OmnivoreKit/Sources/Views/TextChip.swift | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 611979fc9..f6e31a5ed 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -128,6 +128,7 @@ import Views } Spacer() } + .padding(.bottom, 5) .padding(.horizontal) .sheet(isPresented: $showLabelsSheet) { ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 3fcd16e12..79e64e67e 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -33,7 +33,7 @@ public struct TextChip: View { public struct TextChipButton: View { public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton { - TextChipButton(title: "Label Filter", color: .appYellow48, actionType: .add, onTap: onTap) + TextChipButton(title: "Labels", color: Color(.systemGray6), actionType: .show, onTap: onTap) } public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton { @@ -74,6 +74,12 @@ public struct TextChipButton: View { self.color = color self.onTap = onTap self.actionType = actionType + self.foregroundColor = { + if actionType == .show { + return .appGrayText + } + return color.isDark ? .white : .black + }() } let text: String @@ -81,6 +87,7 @@ public struct TextChipButton: View { let onTap: () -> Void let actionType: ActionType let cornerRadius = 20.0 + let foregroundColor: Color public var body: some View { Button(action: onTap) { @@ -91,7 +98,7 @@ public struct TextChipButton: View { .padding(.horizontal, 10) .padding(.vertical, 5) .font(.appFootnote) - .foregroundColor(color.isDark ? .white : .black) + .foregroundColor(foregroundColor) .lineLimit(1) .background(color) .cornerRadius(cornerRadius) From 34ce209738017c5bd641e89eebfc395661ba1e34 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 10:09:41 -0700 Subject: [PATCH 13/19] adjust text chip vertical padding --- apple/OmnivoreKit/Sources/Views/TextChip.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 79e64e67e..2a09428e2 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -96,7 +96,7 @@ public struct TextChipButton: View { Image(systemName: actionType.systemIconName) } .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.vertical, 8) .font(.appFootnote) .foregroundColor(foregroundColor) .lineLimit(1) From b57046b1d9958d4a19a12c6de6de03c4eaf3faca Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 10:19:55 -0700 Subject: [PATCH 14/19] add edit labels buttons to list view cells --- .../Sources/App/Views/Home/HomeFeedViewIOS.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index f6e31a5ed..799146733 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -195,6 +195,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) @@ -223,7 +227,13 @@ import Views } if #available(iOS 15.0, *) { link - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button( + action: { viewModel.itemUnderLabelEdit = item }, + label: { Label("Edit Labels", systemImage: "tag") } + ) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { if !item.isArchived { Button { withAnimation(.linear(duration: 0.4)) { @@ -242,7 +252,7 @@ import Views }.tint(.indigo) } } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button( role: .destructive, action: { From 1e2901c4e71b5ab26394c2118ae4d3df4af12278 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 10:48:25 -0700 Subject: [PATCH 15/19] add label editing to reader view --- .../App/Views/Home/HomeFeedViewIOS.swift | 2 +- .../App/Views/Home/HomeFeedViewModel.swift | 18 ++++++++++++++++-- .../Views/WebReader/WebReaderContainer.swift | 4 ++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 799146733..7f6893916 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -103,7 +103,7 @@ import Views } } .onChange(of: viewModel.selectedLinkItem) { _ in - viewModel.commitProgressUpdates() + viewModel.commitItemUpdates() } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 763615607..4ee67a777 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -11,6 +11,9 @@ 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 @@ -173,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) { @@ -191,6 +198,13 @@ 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 diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 4759c527b..ecf6144a8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -132,6 +132,10 @@ import WebKit Menu( content: { Group { + Button( + action: { homeFeedViewModel.itemUnderLabelEdit = item }, + label: { Label("Edit Labels", systemImage: "tag") } + ) Button( action: { homeFeedViewModel.setLinkArchived( From a75c8e91a464ec257dd5dc0e7036c6724a9c61aa Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 13:42:51 -0700 Subject: [PATCH 16/19] adjust tappable area of text chip buttons --- .../App/Views/Home/HomeFeedViewIOS.swift | 3 +-- .../OmnivoreKit/Sources/Views/TextChip.swift | 26 ++++++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 7f6893916..a5440b461 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -115,7 +115,7 @@ import Views @ObservedObject var viewModel: HomeFeedViewModel var body: some View { - VStack { + VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack { TextChipButton.makeAddLabelButton { @@ -128,7 +128,6 @@ import Views } Spacer() } - .padding(.bottom, 5) .padding(.horizontal) .sheet(isPresented: $showLabelsSheet) { ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 2a09428e2..83ee595a9 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -91,17 +91,23 @@ public struct TextChipButton: View { public var body: some View { Button(action: onTap) { - HStack { - Text(text) - Image(systemName: actionType.systemIconName) + 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) } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .font(.appFootnote) - .foregroundColor(foregroundColor) - .lineLimit(1) - .background(color) - .cornerRadius(cornerRadius) + .contentShape(Rectangle()) } } } From f8ba24bc7912a66fb51b57242ff977595eb7d791 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 15:28:09 -0700 Subject: [PATCH 17/19] add web label color selections and choose a random color as the initial selection --- .../Sources/App/Views/Labels/LabelsView.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index b2af72baa..142e51043 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -102,7 +102,12 @@ struct CreateLabelView: View { GridItem(.fixed(70)) ] - let swatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled() + 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 { @@ -174,10 +179,22 @@ struct CreateLabelView: View { #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", From a7e01275ae7c394a1031cd462e20124d4f617a24 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 17:31:01 -0700 Subject: [PATCH 18/19] fix labels view for mac compilation --- .../Sources/App/Views/Labels/LabelsView.swift | 5 +++-- .../Sources/Views/Colors/Colors.swift | 2 ++ .../OmnivoreKit/Sources/Views/TextChip.swift | 2 +- .../Utils/ToolbarItemPlacementExtension.swift | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 142e51043..24633a1e5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -2,6 +2,7 @@ import Combine import Models import Services import SwiftUI +import Utils import Views struct LabelsView: View { @@ -153,13 +154,13 @@ struct CreateLabelView: View { } .padding() .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .barLeading) { Button( action: { viewModel.showCreateEmailModal = false }, label: { Text("Cancel").foregroundColor(.appGrayTextContrast) } ) } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .barTrailing) { Button( action: { viewModel.createLabel( diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 03c20e65d..bdd97a4ad 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -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) } diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 83ee595a9..c07ce3463 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -33,7 +33,7 @@ public struct TextChip: View { public struct TextChipButton: View { public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton { - TextChipButton(title: "Labels", color: Color(.systemGray6), actionType: .show, onTap: onTap) + TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap) } public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton { diff --git a/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift b/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift new file mode 100644 index 000000000..f4800a5d1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift @@ -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 + } +} From adaadd27d6df350be1146cff02a5de0c945a9a03 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 13 Apr 2022 20:03:41 -0700 Subject: [PATCH 19/19] remove labels from swipe actions --- .../Sources/App/Views/Home/HomeFeedViewIOS.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index a5440b461..23a610b1e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -226,13 +226,7 @@ import Views } if #available(iOS 15.0, *) { link - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button( - action: { viewModel.itemUnderLabelEdit = item }, - label: { Label("Edit Labels", systemImage: "tag") } - ) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { + .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !item.isArchived { Button { withAnimation(.linear(duration: 0.4)) { @@ -251,7 +245,7 @@ import Views }.tint(.indigo) } } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { + .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button( role: .destructive, action: {