diff --git a/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift new file mode 100644 index 000000000..ffb6214fb --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift @@ -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() + @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/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 14916f3db..9db48b14a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -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) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index a6c17d60f..535eb577e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -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 } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 84408f83e..f5e304094 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -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 + } + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift new file mode 100644 index 000000000..852b64e8c --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift @@ -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() + + 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) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index ca2443130..80d6eef94 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -63,6 +63,12 @@ struct ProfileView: View { } Section { + if FeatureFlag.enableLabels { + NavigationLink(destination: LabelsView()) { + Text("Labels") + } + } + NavigationLink(destination: NewsletterEmailsView()) { Text("Emails") } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 5764bac83..2afceda35 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -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 { diff --git a/apple/OmnivoreKit/Sources/Models/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/FeedItem.swift index 30960fbb7..42ff019ea 100644 --- a/apple/OmnivoreKit/Sources/Models/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/FeedItem.swift @@ -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 diff --git a/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift b/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift new file mode 100644 index 000000000..14a157a02 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 89528dbc1..6f50d6b8b 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -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" } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift new file mode 100644 index 000000000..6400f87cf --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift @@ -0,0 +1,62 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func createLabelPublisher( + name: String, + color: String, + description: String? + ) -> AnyPublisher { + enum MutationResult { + case saved(label: FeedItemLabel) + case error(errorCode: Enums.CreateLabelErrorCode) + } + + let selection = Selection { + 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() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift new file mode 100644 index 000000000..134954a4f --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func removeLabelPublisher(labelID: String) -> AnyPublisher { + enum MutationResult { + case success(labelID: String) + case error(errorCode: Enums.DeleteLabelErrorCode) + } + + let selection = Selection { + 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() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift new file mode 100644 index 000000000..fce103d6a --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -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 { + 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() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 4970c2330..8822a0d98 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -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 { enum QueryResult { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift new file mode 100644 index 000000000..5cce49647 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift @@ -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 { + 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() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift index 7419e2649..532ffe88f 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift @@ -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) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift new file mode 100644 index 000000000..998dc8dea --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift @@ -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() + ) +} diff --git a/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift new file mode 100644 index 000000000..7718fbf88 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift @@ -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) + ) + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index a06f1d9e8..ab5196f5a 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index e098badda..55fef0e3b 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 775ad6854..b25bb7c94 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -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)