Merge pull request #389 from omnivore-app/feature/ios-label-editing

Label Assignment Modal [iOS]
This commit is contained in:
Satindar Dhillon 2022-04-07 17:36:03 -07:00 committed by GitHub
commit 0ba8b85953
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 241 additions and 162 deletions

View file

@ -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<FeedItemLabel>()
@Published var labels = [FeedItemLabel]()
var subscriptions = Set<AnyCancellable>()
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)
}
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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()) }
}
}

View file

@ -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<AnyCancellable>()
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: {

View file

@ -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<AnyCancellable>()
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 }
}
}

View file

@ -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
}
}

View file

@ -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)
)
}
}