Merge pull request #399 from omnivore-app/feature/label-filtering-ios
Label filtering ios
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user