diff --git a/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift deleted file mode 100644 index ffb6214fb..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Combine -import Models -import Services -import SwiftUI -import Views - -final class ApplyLabelsViewModel: ObservableObject { - private var hasLoadedInitialLabels = false - @Published var isLoading = true - @Published var selectedLabels = Set() - @Published var labels = [FeedItemLabel]() - - var subscriptions = Set() - - func load(item: FeedItem, dataService: DataService) { - guard !hasLoadedInitialLabels else { return } - - dataService.labelsPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] result in - self?.isLoading = false - self?.labels = result - self?.hasLoadedInitialLabels = true - self?.selectedLabels = Set(item.labels) - } - ) - .store(in: &subscriptions) - } - - func saveChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { - dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink( - receiveCompletion: { _ in }, - receiveValue: { onComplete($0) } - ) - .store(in: &subscriptions) - } -} - -struct ApplyLabelsView: View { - let item: FeedItem - let commitLabelChanges: ([FeedItemLabel]) -> Void - - @EnvironmentObject var dataService: DataService - @Environment(\.presentationMode) private var presentationMode - @StateObject var viewModel = ApplyLabelsViewModel() - - var body: some View { - NavigationView { - if viewModel.isLoading { - EmptyView() - } else { - List(viewModel.labels, id: \.self, selection: $viewModel.selectedLabels) { label in - if let textChip = TextChip(feedItemLabel: label) { - textChip - } else { - Text(label.name) - } - } - .environment(\.editMode, .constant(EditMode.active)) - .navigationTitle("Apply Labels") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button( - action: { presentationMode.wrappedValue.dismiss() }, - label: { Text("Cancel") } - ) - } - ToolbarItem(placement: .navigationBarTrailing) { - Button( - action: { - viewModel.saveChanges(itemID: item.id, dataService: dataService) { labels in - commitLabelChanges(labels) - presentationMode.wrappedValue.dismiss() - } - }, - label: { Text("Save") } - ) - } - } - } - } - .onAppear { - viewModel.load(item: item, dataService: dataService) - } - } -} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 535eb577e..a45f7daad 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -49,6 +49,11 @@ import Views .onSubmit(of: .search) { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) } + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(item: item) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) + } + } } else { HomeFeedView( prefersListLayout: $prefersListLayout, @@ -58,6 +63,11 @@ import Views itemToSnooze: $itemToSnooze, viewModel: viewModel ) + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(item: item) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) + } + } .toolbar { ToolbarItem { if viewModel.isLoading { @@ -167,11 +177,6 @@ import Views } } } - .sheet(item: $viewModel.itemUnderLabelEdit) { item in - ApplyLabelsView(item: item) { labels in - viewModel.updateLabels(itemID: item.id, labels: labels) - } - } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift new file mode 100644 index 000000000..95ff997dd --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -0,0 +1,122 @@ +import Models +import Services +import SwiftUI +import Views + +struct ApplyLabelsView: View { + let item: FeedItem + let commitLabelChanges: ([FeedItemLabel]) -> Void + + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) private var presentationMode + @StateObject var viewModel = LabelsViewModel() + @State private var labelSearchFilter = "" + + var innerBody: some View { + List { + Section(header: Text("Assigned Labels")) { + if viewModel.selectedLabelsForItemInContext.isEmpty { + Text("No labels are currently assigned.") + } + ForEach(viewModel.selectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in + HStack { + TextChip(feedItemLabel: label) + Spacer() + Button( + action: { + withAnimation { + viewModel.removeLabelFromItem(label) + } + }, + label: { Image(systemName: "trash").foregroundColor(.appGrayTextContrast) } + ) + } + } + } + Section(header: Text("Available Labels")) { + ForEach(viewModel.unselectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in + HStack { + TextChip(feedItemLabel: label) + Spacer() + Button( + action: { + withAnimation { + viewModel.addLabelToItem(label) + } + }, + label: { Image(systemName: "plus").foregroundColor(.appGrayTextContrast) } + ) + } + } + } + Section { + Button( + action: { viewModel.showCreateEmailModal = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new Label").foregroundColor(.appGrayTextContrast) + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + } + .navigationTitle("Assign Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Text("Cancel").foregroundColor(.appGrayTextContrast) } + ) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { + viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in + commitLabelChanges(labels) + presentationMode.wrappedValue.dismiss() + } + }, + label: { Text("Save").foregroundColor(.appGrayTextContrast) } + ) + } + } + .sheet(isPresented: $viewModel.showCreateEmailModal) { + CreateLabelView(viewModel: viewModel) + } + } + + var body: some View { + NavigationView { + if viewModel.isLoading { + EmptyView() + + } else { + if #available(iOS 15.0, *) { + innerBody + .searchable( + text: $labelSearchFilter, + placement: .navigationBarDrawer(displayMode: .always) + ) + } else { + innerBody + } + } + } + .onAppear { + viewModel.loadLabels(dataService: dataService, item: item) + } + } +} + +private extension Sequence where Element == FeedItemLabel { + func applySearchFilter(_ searchFilter: String) -> [FeedItemLabel] { + if searchFilter.isEmpty { + return map { $0 } // return the identity of the sequence + } + return filter { $0.name.lowercased().contains(searchFilter.lowercased()) } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift similarity index 68% rename from apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift rename to apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 852b64e8c..e89c6144f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -4,65 +4,6 @@ import Services import SwiftUI import Views -final class LabelsViewModel: ObservableObject { - private var hasLoadedInitialLabels = false - @Published var isLoading = false - @Published var labels = [FeedItemLabel]() - @Published var showCreateEmailModal = false - - var subscriptions = Set() - - func loadLabels(dataService: DataService) { - guard !hasLoadedInitialLabels else { return } - isLoading = true - - dataService.labelsPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] result in - self?.isLoading = false - self?.labels = result - self?.hasLoadedInitialLabels = true - } - ) - .store(in: &subscriptions) - } - - func createLabel(dataService: DataService, name: String, color: Color, description: String?) { - isLoading = true - - dataService.createLabelPublisher( - name: name, - color: color.hex ?? "", - description: description - ).sink( - receiveCompletion: { [weak self] _ in - self?.isLoading = false - }, - receiveValue: { [weak self] result in - self?.isLoading = false - self?.labels.insert(result, at: 0) - self?.showCreateEmailModal = false - } - ) - .store(in: &subscriptions) - } - - func deleteLabel(dataService: DataService, labelID: String) { - isLoading = true - - dataService.removeLabelPublisher(labelID: labelID).sink( - receiveCompletion: { [weak self] _ in - self?.isLoading = false - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - self?.labels.removeAll { $0.id == labelID } - } - ) - .store(in: &subscriptions) - } -} - struct LabelsView: View { @EnvironmentObject var dataService: DataService @StateObject var viewModel = LabelsViewModel() @@ -100,7 +41,7 @@ struct LabelsView: View { .listStyle(InsetListStyle()) #endif } - .onAppear { viewModel.loadLabels(dataService: dataService) } + .onAppear { viewModel.loadLabels(dataService: dataService, item: nil) } } private var innerBody: some View { @@ -111,7 +52,7 @@ struct LabelsView: View { label: { HStack { Image(systemName: "plus.circle.fill").foregroundColor(.green) - Text("Create a new Label") + Text("Create a new Label").foregroundColor(.appGrayTextContrast) Spacer() } } @@ -123,7 +64,7 @@ struct LabelsView: View { Section(header: Text("Labels")) { ForEach(viewModel.labels, id: \.id) { label in HStack { - Text(label.name) + TextChip(feedItemLabel: label) Spacer() Button( action: { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift new file mode 100644 index 000000000..b38109c72 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -0,0 +1,94 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class LabelsViewModel: ObservableObject { + private var hasLoadedInitialLabels = false + @Published var isLoading = false + @Published var selectedLabelsForItemInContext = [FeedItemLabel]() + @Published var unselectedLabelsForItemInContext = [FeedItemLabel]() + @Published var labels = [FeedItemLabel]() + @Published var showCreateEmailModal = false + + var subscriptions = Set() + + func loadLabels(dataService: DataService, item: FeedItem?) { + guard !hasLoadedInitialLabels else { return } + isLoading = true + + dataService.labelsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] allLabels in + self?.isLoading = false + self?.labels = allLabels + self?.hasLoadedInitialLabels = true + if let item = item { + self?.selectedLabelsForItemInContext = item.labels + self?.unselectedLabelsForItemInContext = allLabels.filter { label in + !item.labels.contains(where: { $0.id == label.id }) + } + } + } + ) + .store(in: &subscriptions) + } + + func createLabel(dataService: DataService, name: String, color: Color, description: String?) { + isLoading = true + + dataService.createLabelPublisher( + name: name, + color: color.hex ?? "", + description: description + ).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels.insert(result, at: 0) + self?.unselectedLabelsForItemInContext.insert(result, at: 0) + self?.showCreateEmailModal = false + } + ) + .store(in: &subscriptions) + } + + func deleteLabel(dataService: DataService, labelID: String) { + isLoading = true + + dataService.removeLabelPublisher(labelID: labelID).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + self?.labels.removeAll { $0.id == labelID } + } + ) + .store(in: &subscriptions) + } + + func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { + isLoading = true + dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabelsForItemInContext.map(\.id)).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { onComplete($0) } + ) + .store(in: &subscriptions) + } + + func addLabelToItem(_ label: FeedItemLabel) { + selectedLabelsForItemInContext.insert(label, at: 0) + unselectedLabelsForItemInContext.removeAll { $0.id == label.id } + } + + func removeLabelFromItem(_ label: FeedItemLabel) { + unselectedLabelsForItemInContext.insert(label, at: 0) + selectedLabelsForItemInContext.removeAll { $0.id == label.id } + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift index 7718fbf88..2c20306f6 100644 --- a/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift +++ b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift @@ -45,8 +45,7 @@ public extension Color { } private func toHex() -> String? { - let uic = UIColor(self) - guard let components = uic.cgColor.components, components.count >= 3 else { + guard let components = UIColor(self).cgColor.components, components.count >= 3 else { return nil } let red = Float(components[0]) @@ -60,4 +59,13 @@ public extension Color { lroundf(blue * 255) ) } + + var isDark: Bool { + guard let components = UIColor(self).cgColor.components, components.count >= 3 else { + return false + } + + let lum = 0.2126 * Float(components[0]) + 0.7152 * Float(components[1]) + 0.0722 * Float(components[2]) + return lum < 0.50 + } } diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index b25bb7c94..cb55573e8 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -24,13 +24,9 @@ public struct TextChip: View { .padding(.horizontal, 10) .padding(.vertical, 5) .font(.appFootnote) - .foregroundColor(color) + .foregroundColor(color.isDark ? .white : .black) .lineLimit(1) - .background(color.opacity(0.1)) + .background(color) .cornerRadius(cornerRadius) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(color.opacity(0.3), lineWidth: 1) - ) } }