diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 721680f70..70aa2f66e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -78,6 +78,10 @@ struct ProfileView: View { NavigationLink(destination: NewsletterEmailsView()) { Text("Emails") } + + NavigationLink(destination: SubscriptionsView()) { + Text("Subscriptions") + } } Section { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift new file mode 100644 index 000000000..39808ce52 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift @@ -0,0 +1,140 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class SubscriptionsViewModel: ObservableObject { + @Published var isLoading = true + @Published var subscriptions = [Subscription]() + @Published var popularSubscriptions = [Subscription]() + @Published var hasNetworkError = false + @Published var subscriptionNameToCancel: String? + + func loadSubscriptions(dataService: DataService) async { + isLoading = true + + do { + subscriptions = try await dataService.subscriptions() + } catch { + hasNetworkError = true + } + + isLoading = false + } + + func cancelSubscription(dataService: DataService) async -> Bool { + guard let subscriptionName = subscriptionNameToCancel else { return false } + + do { + try await dataService.deleteSubscription(subscriptionName: subscriptionName) + let index = subscriptions.firstIndex { $0.name == subscriptionName } + if let index = index { + subscriptions.remove(at: index) + } + return true + } catch { + appLogger.debug("failed to remove subscription") + return false + } + } +} + +struct SubscriptionsView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = SubscriptionsViewModel() + @State private var deleteConfirmationShown = false + @State private var progressViewOpacity = 0.0 + + var body: some View { + if viewModel.isLoading { + ProgressView() + .opacity(progressViewOpacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) { + progressViewOpacity = 1 + } + } + .task { await viewModel.loadSubscriptions(dataService: dataService) } + } else if viewModel.hasNetworkError { + VStack { + Text("Sorry, we were unable to retrieve your subscriptions.").multilineTextAlignment(.center) + Button( + action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } }, + label: { Text("Retry") } + ) + .buttonStyle(RoundedRectButtonStyle()) + } + } else { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + } + } + + private var innerBody: some View { + Group { + ForEach(viewModel.subscriptions, id: \.subscriptionID) { subscription in + SubscriptionCell(subscription: subscription) + .swipeActions(edge: .trailing) { + Button( + role: .destructive, + action: { + deleteConfirmationShown = true + viewModel.subscriptionNameToCancel = subscription.name + }, + label: { + Image(systemName: "trash") + } + ) + } + } + } + .alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) { + Button("Yes", role: .destructive) { + Task { + let unsubscribed = await viewModel.cancelSubscription(dataService: dataService) + Snackbar.show(message: unsubscribed ? "Subscription cancelled." : "Could not unsubscribe.") + } + } + Button("No", role: .cancel) { + viewModel.subscriptionNameToCancel = nil + } + } + .navigationTitle("Subscriptions") + } +} + +struct SubscriptionCell: View { + let subscription: Subscription + + var body: some View { + VStack { + VStack(alignment: .leading, spacing: 6) { + Text(subscription.name) + .font(.appCallout) + .lineSpacing(1.25) + .foregroundColor(.appGrayTextContrast) + .fixedSize(horizontal: false, vertical: true) + + if let updatedDate = subscription.updatedAt { + Text("Last received: \(updatedDate.formatted())") + .font(.appCaption) + .foregroundColor(.appGrayText) + .fixedSize(horizontal: false, vertical: true) + } + } + .multilineTextAlignment(.leading) + .padding(.vertical, 8) + .frame(minHeight: 50) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 7ba28267a..a946c7eb7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -1,9 +1,12 @@ import Models +import OSLog import Services import SwiftUI import Utils import Views +let appLogger = Logger(subsystem: "app.omnivore", category: "app-package") + public struct RootView: View { @Environment(\.scenePhase) var scenePhase @StateObject private var viewModel = RootViewModel() diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift new file mode 100644 index 000000000..10d6208a9 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift @@ -0,0 +1,44 @@ +import Foundation + +public struct Subscription { + public let createdAt: Date? + public let description: String? + public let subscriptionID: String + public let name: String + public let newsletterEmailAddress: String + public let status: SubscriptionStatus + public let unsubscribeHttpUrl: String? + public let unsubscribeMailTo: String? + public let updatedAt: Date? + public let url: String? + + public init( + createdAt: Date?, + description: String?, + subscriptionID: String, + name: String, + newsletterEmailAddress: String, + status: SubscriptionStatus, + unsubscribeHttpUrl: String?, + unsubscribeMailTo: String?, + updatedAt: Date?, + url: String? + ) { + self.createdAt = createdAt + self.description = description + self.subscriptionID = subscriptionID + self.name = name + self.newsletterEmailAddress = newsletterEmailAddress + self.status = status + self.unsubscribeHttpUrl = unsubscribeHttpUrl + self.unsubscribeMailTo = unsubscribeMailTo + self.updatedAt = updatedAt + self.url = url + } +} + +public enum SubscriptionStatus { + case active + case deleted + case unsubscribed +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 337fe8a6d..d2faed4b9 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -20,6 +20,136 @@ extension Objects.Subscription: GraphQLWebSocketOperation { // MARK: - Objects enum Objects {} +extension Objects { + struct AddPopularReadError { + let __typename: TypeName = .addPopularReadError + let errorCodes: [String: [Enums.AddPopularReadErrorCode]] + + enum TypeName: String, Codable { + case addPopularReadError = "AddPopularReadError" + } + } +} + +extension Objects.AddPopularReadError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.AddPopularReadErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.AddPopularReadError { + func errorCodes() throws -> [Enums.AddPopularReadErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddPopularReadError = Selection +} + +extension Objects { + struct AddPopularReadSuccess { + let __typename: TypeName = .addPopularReadSuccess + let pageId: [String: String] + + enum TypeName: String, Codable { + case addPopularReadSuccess = "AddPopularReadSuccess" + } + } +} + +extension Objects.AddPopularReadSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "pageId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + pageId = map["pageId"] + } +} + +extension Fields where TypeLock == Objects.AddPopularReadSuccess { + func pageId() throws -> String { + let field = GraphQLField.leaf( + name: "pageId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddPopularReadSuccess = Selection +} + extension Objects { struct ArchiveLinkError { let __typename: TypeName = .archiveLinkError @@ -6691,6 +6821,7 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct Mutation { let __typename: TypeName = .mutation + let addPopularRead: [String: Unions.AddPopularReadResult] let createArticle: [String: Unions.CreateArticleResult] let createArticleSavingRequest: [String: Unions.CreateArticleSavingRequestResult] let createHighlight: [String: Unions.CreateHighlightResult] @@ -6725,11 +6856,13 @@ extension Objects { let setShareHighlight: [String: Unions.SetShareHighlightResult] let setUserPersonalization: [String: Unions.SetUserPersonalizationResult] let signup: [String: Unions.SignupResult] + let subscribe: [String: Unions.SubscribeResult] let unsubscribe: [String: Unions.UnsubscribeResult] let updateHighlight: [String: Unions.UpdateHighlightResult] let updateHighlightReply: [String: Unions.UpdateHighlightReplyResult] let updateLabel: [String: Unions.UpdateLabelResult] let updateLinkShareInfo: [String: Unions.UpdateLinkShareInfoResult] + let updatePage: [String: Unions.UpdatePageResult] let updateReminder: [String: Unions.UpdateReminderResult] let updateSharedComment: [String: Unions.UpdateSharedCommentResult] let updateUser: [String: Unions.UpdateUserResult] @@ -6754,6 +6887,10 @@ extension Objects.Mutation: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "addPopularRead": + if let value = try container.decode(Unions.AddPopularReadResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "createArticle": if let value = try container.decode(Unions.CreateArticleResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -6890,6 +7027,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.SignupResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "subscribe": + if let value = try container.decode(Unions.SubscribeResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "unsubscribe": if let value = try container.decode(Unions.UnsubscribeResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -6910,6 +7051,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.UpdateLinkShareInfoResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "updatePage": + if let value = try container.decode(Unions.UpdatePageResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "updateReminder": if let value = try container.decode(Unions.UpdateReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -6940,6 +7085,7 @@ extension Objects.Mutation: Decodable { } } + addPopularRead = map["addPopularRead"] createArticle = map["createArticle"] createArticleSavingRequest = map["createArticleSavingRequest"] createHighlight = map["createHighlight"] @@ -6974,11 +7120,13 @@ extension Objects.Mutation: Decodable { setShareHighlight = map["setShareHighlight"] setUserPersonalization = map["setUserPersonalization"] signup = map["signup"] + subscribe = map["subscribe"] unsubscribe = map["unsubscribe"] updateHighlight = map["updateHighlight"] updateHighlightReply = map["updateHighlightReply"] updateLabel = map["updateLabel"] updateLinkShareInfo = map["updateLinkShareInfo"] + updatePage = map["updatePage"] updateReminder = map["updateReminder"] updateSharedComment = map["updateSharedComment"] updateUser = map["updateUser"] @@ -6988,6 +7136,25 @@ extension Objects.Mutation: Decodable { } extension Fields where TypeLock == Objects.Mutation { + func addPopularRead(name: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "addPopularRead", + arguments: [Argument(name: "name", type: "String!", value: name)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.addPopularRead[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func createArticle(input: InputObjects.CreateArticleInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "createArticle", @@ -7634,6 +7801,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func subscribe(name: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "subscribe", + arguments: [Argument(name: "name", type: "String!", value: name)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.subscribe[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func unsubscribe(name: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "unsubscribe", @@ -7729,6 +7915,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func updatePage(input: InputObjects.UpdatePageInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "updatePage", + arguments: [Argument(name: "input", type: "UpdatePageInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatePage[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func updateReminder(input: InputObjects.UpdateReminderInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "updateReminder", @@ -10225,6 +10430,7 @@ extension Objects { let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] let shortId: [String: String] + let siteName: [String: String] let slug: [String: String] let state: [String: Enums.ArticleSavingRequestStatus] let subscription: [String: String] @@ -10324,6 +10530,10 @@ extension Objects.SearchItem: Decodable { 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) @@ -10384,6 +10594,7 @@ extension Objects.SearchItem: Decodable { readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] shortId = map["shortId"] + siteName = map["siteName"] slug = map["slug"] state = map["state"] subscription = map["subscription"] @@ -10682,6 +10893,21 @@ extension Fields where TypeLock == Objects.SearchItem { } } + 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 slug() throws -> String { let field = GraphQLField.leaf( name: "slug", @@ -12332,6 +12558,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias SignupSuccess = Selection } +extension Objects { + struct SubscribeError { + let __typename: TypeName = .subscribeError + let errorCodes: [String: [Enums.SubscribeErrorCode]] + + enum TypeName: String, Codable { + case subscribeError = "SubscribeError" + } + } +} + +extension Objects.SubscribeError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SubscribeErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.SubscribeError { + func errorCodes() throws -> [Enums.SubscribeErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SubscribeError = Selection +} + +extension Objects { + struct SubscribeSuccess { + let __typename: TypeName = .subscribeSuccess + let subscriptions: [String: [Objects.Subscription]] + + enum TypeName: String, Codable { + case subscribeSuccess = "SubscribeSuccess" + } + } +} + +extension Objects.SubscribeSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "subscriptions": + if let value = try container.decode([Objects.Subscription]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + subscriptions = map["subscriptions"] + } +} + +extension Fields where TypeLock == Objects.SubscribeSuccess { + func subscriptions(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "subscriptions", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.subscriptions[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SubscribeSuccess = Selection +} + extension Objects { struct Subscription { let __typename: TypeName = .subscription @@ -13386,6 +13743,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias UpdateLinkShareInfoSuccess = Selection } +extension Objects { + struct UpdatePageError { + let __typename: TypeName = .updatePageError + let errorCodes: [String: [Enums.UpdatePageErrorCode]] + + enum TypeName: String, Codable { + case updatePageError = "UpdatePageError" + } + } +} + +extension Objects.UpdatePageError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdatePageErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.UpdatePageError { + func errorCodes() throws -> [Enums.UpdatePageErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdatePageError = Selection +} + +extension Objects { + struct UpdatePageSuccess { + let __typename: TypeName = .updatePageSuccess + let updatedPage: [String: Objects.Page] + + enum TypeName: String, Codable { + case updatePageSuccess = "UpdatePageSuccess" + } + } +} + +extension Objects.UpdatePageSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "updatedPage": + if let value = try container.decode(Objects.Page?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + updatedPage = map["updatedPage"] + } +} + +extension Fields where TypeLock == Objects.UpdatePageSuccess { + func updatedPage(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "updatedPage", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedPage[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdatePageSuccess = Selection +} + extension Objects { struct UpdateReminderError { let __typename: TypeName = .updateReminderError @@ -14891,6 +15379,80 @@ enum Interfaces {} // MARK: - Unions enum Unions {} +extension Unions { + struct AddPopularReadResult { + let __typename: TypeName + let errorCodes: [String: [Enums.AddPopularReadErrorCode]] + let pageId: [String: String] + + enum TypeName: String, Codable { + case addPopularReadError = "AddPopularReadError" + case addPopularReadSuccess = "AddPopularReadSuccess" + } + } +} + +extension Unions.AddPopularReadResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.AddPopularReadErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + pageId = map["pageId"] + } +} + +extension Fields where TypeLock == Unions.AddPopularReadResult { + func on(addPopularReadError: Selection, addPopularReadSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "AddPopularReadError", selection: addPopularReadError.selection), GraphQLField.fragment(type: "AddPopularReadSuccess", selection: addPopularReadSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .addPopularReadError: + let data = Objects.AddPopularReadError(errorCodes: data.errorCodes) + return try addPopularReadError.decode(data: data) + case .addPopularReadSuccess: + let data = Objects.AddPopularReadSuccess(pageId: data.pageId) + return try addPopularReadSuccess.decode(data: data) + } + case .mocking: + return addPopularReadError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddPopularReadResult = Selection +} + extension Unions { struct ArchiveLinkResult { let __typename: TypeName @@ -18065,6 +18627,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias SignupResult = Selection } +extension Unions { + struct SubscribeResult { + let __typename: TypeName + let errorCodes: [String: [Enums.SubscribeErrorCode]] + let subscriptions: [String: [Objects.Subscription]] + + enum TypeName: String, Codable { + case subscribeError = "SubscribeError" + case subscribeSuccess = "SubscribeSuccess" + } + } +} + +extension Unions.SubscribeResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SubscribeErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "subscriptions": + if let value = try container.decode([Objects.Subscription]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + subscriptions = map["subscriptions"] + } +} + +extension Fields where TypeLock == Unions.SubscribeResult { + func on(subscribeError: Selection, subscribeSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SubscribeError", selection: subscribeError.selection), GraphQLField.fragment(type: "SubscribeSuccess", selection: subscribeSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .subscribeError: + let data = Objects.SubscribeError(errorCodes: data.errorCodes) + return try subscribeError.decode(data: data) + case .subscribeSuccess: + let data = Objects.SubscribeSuccess(subscriptions: data.subscriptions) + return try subscribeSuccess.decode(data: data) + } + case .mocking: + return subscribeError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SubscribeResult = Selection +} + extension Unions { struct SubscriptionsResult { let __typename: TypeName @@ -18509,6 +19145,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias UpdateLinkShareInfoResult = Selection } +extension Unions { + struct UpdatePageResult { + let __typename: TypeName + let errorCodes: [String: [Enums.UpdatePageErrorCode]] + let updatedPage: [String: Objects.Page] + + enum TypeName: String, Codable { + case updatePageError = "UpdatePageError" + case updatePageSuccess = "UpdatePageSuccess" + } + } +} + +extension Unions.UpdatePageResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdatePageErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updatedPage": + if let value = try container.decode(Objects.Page?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + updatedPage = map["updatedPage"] + } +} + +extension Fields where TypeLock == Unions.UpdatePageResult { + func on(updatePageError: Selection, updatePageSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "UpdatePageError", selection: updatePageError.selection), GraphQLField.fragment(type: "UpdatePageSuccess", selection: updatePageSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .updatePageError: + let data = Objects.UpdatePageError(errorCodes: data.errorCodes) + return try updatePageError.decode(data: data) + case .updatePageSuccess: + let data = Objects.UpdatePageSuccess(updatedPage: data.updatedPage) + return try updatePageSuccess.decode(data: data) + } + case .mocking: + return updatePageError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdatePageResult = Selection +} + extension Unions { struct UpdateReminderResult { let __typename: TypeName @@ -19048,6 +19758,17 @@ extension Selection where TypeLock == Never, Type == Never { // MARK: - Enums enum Enums {} +extension Enums { + /// AddPopularReadErrorCode + enum AddPopularReadErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// ArchiveLinkErrorCode enum ArchiveLinkErrorCode: String, CaseIterable, Codable { @@ -19551,6 +20272,8 @@ extension Enums { extension Enums { /// SortBy enum SortBy: String, CaseIterable, Codable { + case publishedAt = "PUBLISHED_AT" + case savedAt = "SAVED_AT" case score = "SCORE" @@ -19568,6 +20291,19 @@ extension Enums { } } +extension Enums { + /// SubscribeErrorCode + enum SubscribeErrorCode: String, CaseIterable, Codable { + case alreadySubscribed = "ALREADY_SUBSCRIBED" + + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// SubscriptionsErrorCode enum SubscriptionsErrorCode: String, CaseIterable, Codable { @@ -19649,6 +20385,21 @@ extension Enums { } } +extension Enums { + /// UpdatePageErrorCode + enum UpdatePageErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case forbidden = "FORBIDDEN" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + + case updateFailed = "UPDATE_FAILED" + } +} + extension Enums { /// UpdateReminderErrorCode enum UpdateReminderErrorCode: String, CaseIterable, Codable { @@ -20604,6 +21355,29 @@ extension InputObjects { } } +extension InputObjects { + struct UpdatePageInput: Encodable, Hashable { + var description: OptionalArgument = .absent() + + var pageId: String + + var title: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if description.hasValue { try container.encode(description, forKey: .description) } + try container.encode(pageId, forKey: .pageId) + if title.hasValue { try container.encode(title, forKey: .title) } + } + + enum CodingKeys: String, CodingKey { + case description + case pageId + case title + } + } +} + extension InputObjects { struct UpdateReminderInput: Encodable, Hashable { var archiveUntil: Bool diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift new file mode 100644 index 000000000..d8bdf3289 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift @@ -0,0 +1,43 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func deleteSubscription(subscriptionName: String) async throws { + enum MutationResult { + case success(id: String) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + unsubscribeError: .init { .error(errorMessage: (try $0.errorCodes().first ?? .unauthorized).rawValue) }, + unsubscribeSuccess: .init { .success(id: try $0.subscription(selection: Selection.Subscription { try $0.id() })) } + ) + } + + let mutation = Selection.Mutation { + try $0.unsubscribe(name: subscriptionName, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { mutationResult in + guard let payload = try? mutationResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case .success: + continuation.resume() + case .error: + continuation.resume(throwing: BasicError.message(messageText: "Subscriptions fetch error")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift new file mode 100644 index 000000000..2c2976126 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift @@ -0,0 +1,74 @@ +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func subscriptions() async throws -> [Subscription] { + enum QueryResult { + case success(result: [Subscription]) + case error(error: String) + } + + let subsciptionSelection = Selection.Subscription { + Subscription( + createdAt: try $0.createdAt().value, + description: try $0.description(), + subscriptionID: try $0.id(), + name: try $0.name(), + newsletterEmailAddress: try $0.newsletterEmail(), + status: try SubscriptionStatus.make(from: $0.status()), + unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(), + unsubscribeMailTo: try $0.unsubscribeMailTo(), + updatedAt: try $0.updatedAt().value, + url: try $0.url() + ) + } + + let selection = Selection { + try $0.on( + subscriptionsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + subscriptionsSuccess: .init { + QueryResult.success(result: try $0.subscriptions(selection: subsciptionSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.subscriptions(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case let .success(result: result): + continuation.resume(returning: result) + case .error: + continuation.resume(throwing: BasicError.message(messageText: "Subscriptions fetch error")) + } + } + } + } +} + +extension SubscriptionStatus { + static func make(from status: Enums.SubscriptionStatus) -> SubscriptionStatus { + switch status { + case .active: + return .active + case .deleted: + return .deleted + case .unsubscribed: + return .unsubscribed + } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift index 64f72fcb7..6b6e974c6 100644 --- a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift +++ b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift @@ -22,16 +22,16 @@ public struct SolidCapsuleButtonStyle: ButtonStyle { } } -struct RoundedRectButtonStyle: ButtonStyle { +public struct RoundedRectButtonStyle: ButtonStyle { let backgroundColor: Color let textColor: Color - init(color: Color = .appButtonBackground, textColor: Color = .appGrayText) { + public init(color: Color = .appButtonBackground, textColor: Color = .appGrayText) { self.backgroundColor = color self.textColor = textColor } - func makeBody(configuration: Configuration) -> some View { + public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.appBody) .foregroundColor(textColor)