mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #366 from omnivore-app/feature/label-chips-ios
Label Creation Form
This commit is contained in:
commit
0f18cac6a8
21 changed files with 732 additions and 22 deletions
87
apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift
Normal file
87
apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ struct GridCardNavigationLink: View {
|
|||
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
|
||||
}
|
||||
}
|
||||
.aspectRatio(2.1, contentMode: .fill)
|
||||
.aspectRatio(1.8, contentMode: .fill)
|
||||
.scaleEffect(scale)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,11 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(item: item) { labels in
|
||||
viewModel.updateLabels(itemID: item.id, labels: labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -323,6 +328,8 @@ import Views
|
|||
case .delete:
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
case .editLabels:
|
||||
viewModel.itemUnderLabelEdit = item
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
@Published var items = [FeedItem]()
|
||||
@Published var isLoading = false
|
||||
@Published var showPushNotificationPrimer = false
|
||||
@Published var itemUnderLabelEdit: FeedItem?
|
||||
var cursor: String?
|
||||
var sendProgressUpdates = false
|
||||
|
||||
|
|
@ -182,4 +183,11 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
items[index].readingProgress = progress
|
||||
}
|
||||
}
|
||||
|
||||
func updateLabels(itemID: String, labels: [FeedItemLabel]) {
|
||||
guard let item = items.first(where: { $0.id == itemID }) else { return }
|
||||
if let index = items.firstIndex(of: item) {
|
||||
items[index].labels = labels
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
195
apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift
Normal file
195
apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import Combine
|
||||
import Models
|
||||
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()
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var labelToRemoveID: String?
|
||||
|
||||
let footerText = "Use labels to create curated collections of links."
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if #available(iOS 15.0, *) {
|
||||
Form {
|
||||
innerBody
|
||||
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
if let labelID = labelToRemoveID {
|
||||
withAnimation {
|
||||
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
|
||||
}
|
||||
}
|
||||
self.labelToRemoveID = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Form { innerBody }
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.onAppear { viewModel.loadLabels(dataService: dataService) }
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section(footer: Text(footerText)) {
|
||||
Button(
|
||||
action: { viewModel.showCreateEmailModal = true },
|
||||
label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill").foregroundColor(.green)
|
||||
Text("Create a new Label")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
|
||||
if !viewModel.labels.isEmpty {
|
||||
Section(header: Text("Labels")) {
|
||||
ForEach(viewModel.labels, id: \.id) { label in
|
||||
HStack {
|
||||
Text(label.name)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
labelToRemoveID = label.id
|
||||
showDeleteConfirmation = true
|
||||
},
|
||||
label: { Image(systemName: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Labels")
|
||||
.sheet(isPresented: $viewModel.showCreateEmailModal) {
|
||||
CreateLabelView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateLabelView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@ObservedObject var viewModel: LabelsViewModel
|
||||
|
||||
@State private var newLabelName = ""
|
||||
@State private var newLabelColor = Color.clear
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 16) {
|
||||
TextField("Label Name", text: $newLabelName)
|
||||
.keyboardType(.alphabet)
|
||||
.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)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(
|
||||
action: { viewModel.showCreateEmailModal = false },
|
||||
label: {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create New Label")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,12 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
if FeatureFlag.enableLabels {
|
||||
NavigationLink(destination: LabelsView()) {
|
||||
Text("Labels")
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(destination: NewsletterEmailsView()) {
|
||||
Text("Emails")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,8 @@ struct SafariWebLink: Identifiable {
|
|||
}
|
||||
|
||||
func encodeHighlightResult(_ highlight: Highlight) -> [String: Any]? {
|
||||
let data = try? JSONEncoder().encode(highlight)
|
||||
if let data = data, let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] {
|
||||
return dictionary
|
||||
}
|
||||
return nil
|
||||
guard let data = try? JSONEncoder().encode(highlight) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
|
||||
}
|
||||
|
||||
final class WebReaderViewModel: ObservableObject {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
|
|||
public let slug: String
|
||||
public let isArchived: Bool
|
||||
public let contentReader: String?
|
||||
public var labels: [FeedItemLabel]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
|
|
@ -46,7 +47,8 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
|
|||
publishDate: Date?,
|
||||
slug: String,
|
||||
isArchived: Bool,
|
||||
contentReader: String?
|
||||
contentReader: String?,
|
||||
labels: [FeedItemLabel]
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
|
|
@ -65,10 +67,12 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
|
|||
self.slug = slug
|
||||
self.isArchived = isArchived
|
||||
self.contentReader = contentReader
|
||||
self.labels = labels
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url
|
||||
// swiftlint:disable:next line_length
|
||||
case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url, labels
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
|
|
@ -85,6 +89,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
|
|||
contentReader = try container.decode(String.self, forKey: .contentReader)
|
||||
pageURLString = try container.decode(String.self, forKey: .url)
|
||||
isArchived = try container.decode(Bool.self, forKey: .isArchived)
|
||||
labels = try container.decode([FeedItemLabel].self, forKey: .labels)
|
||||
|
||||
self.onDeviceImageURLString = nil
|
||||
self.documentDirectoryPath = nil
|
||||
|
|
|
|||
23
apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift
Normal file
23
apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Foundation
|
||||
|
||||
public struct FeedItemLabel: Decodable, Hashable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let color: String
|
||||
public let createdAt: Date?
|
||||
public let description: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
color: String,
|
||||
createdAt: Date?,
|
||||
description: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.createdAt = createdAt
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
|
@ -2970,8 +2970,11 @@ extension Objects {
|
|||
let savedByViewer: [String: Bool]
|
||||
let shareInfo: [String: Objects.LinkShareInfo]
|
||||
let sharedComment: [String: String]
|
||||
let siteIcon: [String: String]
|
||||
let siteName: [String: String]
|
||||
let slug: [String: String]
|
||||
let title: [String: String]
|
||||
let uploadFileId: [String: String]
|
||||
let url: [String: String]
|
||||
|
||||
enum TypeName: String, Codable {
|
||||
|
|
@ -3088,6 +3091,14 @@ extension Objects.Article: Decodable {
|
|||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "siteIcon":
|
||||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "siteName":
|
||||
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)
|
||||
|
|
@ -3096,6 +3107,10 @@ extension Objects.Article: Decodable {
|
|||
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)
|
||||
|
|
@ -3134,8 +3149,11 @@ extension Objects.Article: Decodable {
|
|||
savedByViewer = map["savedByViewer"]
|
||||
shareInfo = map["shareInfo"]
|
||||
sharedComment = map["sharedComment"]
|
||||
siteIcon = map["siteIcon"]
|
||||
siteName = map["siteName"]
|
||||
slug = map["slug"]
|
||||
title = map["title"]
|
||||
uploadFileId = map["uploadFileId"]
|
||||
url = map["url"]
|
||||
}
|
||||
}
|
||||
|
|
@ -3587,6 +3605,51 @@ extension Fields where TypeLock == Objects.Article {
|
|||
return selection.mock()
|
||||
}
|
||||
}
|
||||
|
||||
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 siteName() throws -> String? {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "siteName",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
return data.siteName[field.alias!]
|
||||
case .mocking:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func siteIcon() throws -> String? {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "siteIcon",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
return data.siteIcon[field.alias!]
|
||||
case .mocking:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Selection where TypeLock == Never, Type == Never {
|
||||
|
|
@ -10784,7 +10847,7 @@ extension Fields where TypeLock == Objects.Label {
|
|||
}
|
||||
}
|
||||
|
||||
func createdAt() throws -> DateTime {
|
||||
func createdAt() throws -> DateTime? {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "createdAt",
|
||||
arguments: []
|
||||
|
|
@ -10793,12 +10856,9 @@ extension Fields where TypeLock == Objects.Label {
|
|||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
if let data = data.createdAt[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
return data.createdAt[field.alias!]
|
||||
case .mocking:
|
||||
return DateTime.mockValue
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16779,6 +16839,10 @@ extension Enums {
|
|||
/// SortBy
|
||||
enum SortBy: String, CaseIterable, Codable {
|
||||
case updatedTime = "UPDATED_TIME"
|
||||
|
||||
case score = "SCORE"
|
||||
|
||||
case savedAt = "SAVED_AT"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16956,6 +17020,8 @@ extension Enums {
|
|||
case payloadTooLarge = "PAYLOAD_TOO_LARGE"
|
||||
|
||||
case uploadFileMissing = "UPLOAD_FILE_MISSING"
|
||||
|
||||
case elasticError = "ELASTIC_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func createLabelPublisher(
|
||||
name: String,
|
||||
color: String,
|
||||
description: String?
|
||||
) -> AnyPublisher<FeedItemLabel, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(label: FeedItemLabel)
|
||||
case error(errorCode: Enums.CreateLabelErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.CreateLabelResult> {
|
||||
try $0.on(
|
||||
createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) },
|
||||
createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.createLabel(
|
||||
input: InputObjects.CreateLabelInput(
|
||||
name: name,
|
||||
color: color,
|
||||
description: OptionalArgument(description)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(label: label):
|
||||
promise(.success(label))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func removeLabelPublisher(labelID: String) -> AnyPublisher<Bool, BasicError> {
|
||||
enum MutationResult {
|
||||
case success(labelID: String)
|
||||
case error(errorCode: Enums.DeleteLabelErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.DeleteLabelResult> {
|
||||
try $0.on(
|
||||
deleteLabelSuccess: .init {
|
||||
.success(labelID: try $0.label(selection: Selection.Label { try $0.id() }))
|
||||
},
|
||||
deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.deleteLabel(id: labelID, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if payload.errors != nil {
|
||||
promise(.failure(.message(messageText: "Error removing label")))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .success:
|
||||
promise(.success(true))
|
||||
case .error:
|
||||
promise(.failure(.message(messageText: "Error removing label")))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "Error removing label")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateArticleLabelsPublisher(itemID: String, labelIDs: [String]) -> AnyPublisher<[FeedItemLabel], BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(feedItem: [FeedItemLabel])
|
||||
case error(errorCode: Enums.SetLabelsErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SetLabelsResult> {
|
||||
try $0.on(
|
||||
setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) },
|
||||
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLabels(
|
||||
input: InputObjects.SetLabelsInput(
|
||||
linkId: itemID,
|
||||
labelIds: labelIDs
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
if let graphqlError = payload.errors {
|
||||
promise(.failure(.message(messageText: graphqlError.first.debugDescription)))
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(labels):
|
||||
promise(.success(labels))
|
||||
case .error:
|
||||
promise(.failure(.message(messageText: "failed to set labels")))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "failed to set labels")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import Foundation
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
public extension DataService {
|
||||
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
|
||||
enum QueryResult {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func labelsPublisher() -> AnyPublisher<[FeedItemLabel], ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: [FeedItemLabel])
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.LabelsResult> {
|
||||
try $0.on(labelsSuccess: .init {
|
||||
QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list))
|
||||
},
|
||||
labelsError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
})
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.labels(selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(query, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
promise(.success(result))
|
||||
case .error:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +143,8 @@ let homeFeedItemSelection = Selection.Article {
|
|||
publishDate: try $0.publishedAt()?.value,
|
||||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
let feedItemLabelSelection = Selection.Label {
|
||||
FeedItemLabel(
|
||||
id: try $0.id(),
|
||||
name: try $0.name(),
|
||||
color: try $0.color(),
|
||||
createdAt: try $0.createdAt()?.value,
|
||||
description: try $0.description()
|
||||
)
|
||||
}
|
||||
63
apple/OmnivoreKit/Sources/Utils/ColorUtils.swift
Normal file
63
apple/OmnivoreKit/Sources/Utils/ColorUtils.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension Color {
|
||||
/// Inititializes a `Color` from a hex value
|
||||
/// - Parameter hex: Color hex value. ex: `#FFFFFF`
|
||||
///
|
||||
init?(hex: String) {
|
||||
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
|
||||
var red: CGFloat = 0.0
|
||||
var green: CGFloat = 0.0
|
||||
var blue: CGFloat = 0.0
|
||||
var alpha: CGFloat = 1.0
|
||||
|
||||
let length = hexSanitized.count
|
||||
|
||||
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
||||
|
||||
if length == 6 {
|
||||
red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
||||
green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
||||
blue = CGFloat(rgb & 0x0000FF) / 255.0
|
||||
} else if length == 8 {
|
||||
red = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0
|
||||
green = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0
|
||||
blue = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0
|
||||
alpha = CGFloat(rgb & 0x0000_00FF) / 255.0
|
||||
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.init(red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
|
||||
var hex: String? {
|
||||
if let hexValue = toHex() {
|
||||
return "#\(hexValue)"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func toHex() -> String? {
|
||||
let uic = UIColor(self)
|
||||
guard let components = uic.cgColor.components, components.count >= 3 else {
|
||||
return nil
|
||||
}
|
||||
let red = Float(components[0])
|
||||
let green = Float(components[1])
|
||||
let blue = Float(components[2])
|
||||
|
||||
return String(
|
||||
format: "%02lX%02lX%02lX",
|
||||
lroundf(red * 255),
|
||||
lroundf(green * 255),
|
||||
lroundf(blue * 255)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,6 @@ public enum FeatureFlag {
|
|||
public static let enablePushNotifications = false
|
||||
public static let enableShareButton = false
|
||||
public static let enableSnooze = false
|
||||
public static let showFeedItemTags = false
|
||||
public static let enableLabels = true
|
||||
public static let useLocalWebView = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Utils
|
|||
public enum GridCardAction {
|
||||
case toggleArchiveStatus
|
||||
case delete
|
||||
case editLabels
|
||||
}
|
||||
|
||||
public struct GridCard: View {
|
||||
|
|
@ -42,6 +43,10 @@ public struct GridCard: View {
|
|||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: { menuActionHandler(.editLabels) },
|
||||
label: { Label("Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.toggleArchiveStatus) },
|
||||
label: {
|
||||
|
|
@ -156,11 +161,12 @@ public struct GridCard: View {
|
|||
.onTapGesture { tapHandler() }
|
||||
|
||||
// Category Labels
|
||||
if FeatureFlag.showFeedItemTags {
|
||||
if FeatureFlag.enableLabels {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
TextChip(text: "label", color: .red)
|
||||
TextChip(text: "longer label", color: .blue)
|
||||
ForEach(item.labels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 30)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public struct TextChip: View {
|
||||
public init(text: String, color: Color) {
|
||||
self.text = text
|
||||
self.color = color
|
||||
}
|
||||
|
||||
public init?(feedItemLabel: FeedItemLabel) {
|
||||
guard let color = Color(hex: feedItemLabel.color) else { return nil }
|
||||
|
||||
self.text = feedItemLabel.name
|
||||
self.color = color
|
||||
}
|
||||
|
||||
struct TextChip: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let cornerRadius = 20.0
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
|
|
|
|||
Loading…
Reference in a new issue