mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #389 from omnivore-app/feature/ios-label-editing
Label Assignment Modal [iOS]
This commit is contained in:
commit
0ba8b85953
7 changed files with 241 additions and 162 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift
Normal file
122
apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift
Normal 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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue