From d6ccc5b31878c1b16d020112a16d1bfb8b4ecd4a Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Wed, 6 Jul 2022 13:27:43 -0700 Subject: [PATCH] move share extension view from Views to App --- .../Share/ShareExtensionView.swift | 222 +++++++++++-- .../Share/ShareExtensionViewComponents.swift | 53 ++++ .../Share/ShareExtensionViewModel.swift | 79 +++++ .../Sources/Views/AsyncLoadingImage.swift | 8 +- .../Sources/Views/Buttons/ButtonStyles.swift | 6 +- .../OmnivoreKit/Sources/Views/LocalText.swift | 10 +- .../Sources/Views/ShareExtensionView.swift | 296 ------------------ 7 files changed, 339 insertions(+), 335 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewComponents.swift create mode 100644 apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift delete mode 100644 apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionView.swift index 7d0f1fa29..5d1306885 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionView.swift @@ -4,33 +4,6 @@ import SwiftUI import Utils import Views -public class ShareExtensionViewModel: ObservableObject { - @Published var title: String? - @Published var debugText: String? - - let saveService = ExtensionSaveService() - - func handleReadNowAction(requestId: String, extensionContext: NSExtensionContext?) { - #if os(iOS) - if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication { - let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)") - application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) - } - #endif - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - } - - func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) { - if let extensionContext = extensionContext { - saveService.save(extensionContext, shareExtensionViewModel: shareExtensionViewModel) - } else { - DispatchQueue.main.async { - shareExtensionViewModel.status = .failed(error: .unknown(description: "Internal Error")) - } - } - } -} - struct ShareExtensionView: View { let extensionContext: NSExtensionContext? @StateObject private var viewModel = ShareExtensionViewModel() @@ -52,3 +25,198 @@ struct ShareExtensionView: View { ) } } + +public struct ShareExtensionChildView: View { + let viewModel: ShareExtensionChildViewModel + let onAppearAction: () -> Void + let readNowButtonAction: (String) -> Void + let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void + + @State var reminderTime: ReminderTime? + @State var hideUntilReminded = false + + public init( + viewModel: ShareExtensionChildViewModel, + onAppearAction: @escaping () -> Void, + readNowButtonAction: @escaping (String) -> Void, + dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void + ) { + self.viewModel = viewModel + self.onAppearAction = onAppearAction + self.readNowButtonAction = readNowButtonAction + self.dismissButtonTappedAction = dismissButtonTappedAction + } + + private func handleReminderTimeSelection(_ selectedTime: ReminderTime) { + if selectedTime == reminderTime { + reminderTime = nil + hideUntilReminded = false + } else { + reminderTime = selectedTime + hideUntilReminded = true + } + } + + private var titleText: String { + switch viewModel.status { + case .saved, .synced, .syncFailed(error: _): + return "Saved to Omnivore" + case .processing: + return "Saving to Omnivore" + case .failed(error: _): + return "Error saving to Omnivore" + } + } + + private var cloudIconName: String { + switch viewModel.status { + case .synced: + return "checkmark.icloud" + case .saved, .processing: + return "icloud" + case .failed(error: _), .syncFailed(error: _): + return "exclamationmark.icloud" + } + } + + private var cloudIconColor: Color { + switch viewModel.status { + case .saved: + return .appGrayText + case .processing: + return .clear + case .failed(error: _), .syncFailed(error: _): + return .red + case .synced: + return .blue + } + } + + private func localImage(from url: URL) -> Image? { + #if os(iOS) + if let data = try? Data(contentsOf: url), let img = UIImage(data: data) { + return Image(uiImage: img) + } + #else + if let data = try? Data(contentsOf: url), let img = NSImage(data: data) { + return Image(nsImage: img) + } + #endif + return nil + } + + public var previewCard: some View { + HStack { + if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) { + if !iconURL.isFileURL { + AsyncLoadingImage(url: iconURL) { imageStatus in + if case let AsyncImageStatus.loaded(image) = imageStatus { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + .clipped() + } else { + Color.appButtonBackground + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + } + } + } else { + if let localImage = localImage(from: iconURL) { + localImage + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + .clipped() + } else { + Color.appButtonBackground + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + } + } + } else { + Color.appButtonBackground + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + } + + VStack(alignment: .leading) { + Text(viewModel.title ?? "") + .lineLimit(1) + .foregroundColor(.appGrayText) + .font(Font.system(size: 15, weight: .semibold)) + Text(viewModel.url ?? "") + .lineLimit(1) + .foregroundColor(.appGrayText) + .font(Font.system(size: 12, weight: .regular)) + } + Spacer() + VStack { + Spacer() + Image(systemName: cloudIconName) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 12, height: 12, alignment: .trailing) + .foregroundColor(cloudIconColor) + // .padding(.trailing, 6) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8)) + } + } + .background(Color.appButtonBackground) + .frame(maxWidth: .infinity, maxHeight: 61) + .cornerRadius(8) + } + + public var body: some View { + VStack(alignment: .leading) { + Text(titleText) + .foregroundColor(.appGrayText) + .font(Font.system(size: 17, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 23) + .padding(.bottom, 12) + + Rectangle() + .foregroundColor(.appGrayText) + .frame(maxWidth: .infinity, maxHeight: 1) + .opacity(0.06) + .padding(.top, 0) + .padding(.bottom, 18) + + previewCard + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + + Spacer() + + HStack { + Button( + action: { readNowButtonAction(self.viewModel.requestId) }, + label: { Text("Read Now").frame(maxWidth: .infinity) } + ) + .buttonStyle(RoundedRectButtonStyle()) + + Button( + action: { + dismissButtonTappedAction(reminderTime, hideUntilReminded) + }, + label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + ) + .buttonStyle(RoundedRectButtonStyle()) + } + .padding(.horizontal) + .padding(.bottom) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .onAppear { + onAppearAction() + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewComponents.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewComponents.swift new file mode 100644 index 000000000..fa8f67673 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewComponents.swift @@ -0,0 +1,53 @@ +import SwiftUI +import Views + +// TODO: maybe move this into Views package? +struct IconButtonView: View { + let title: String + let systemIconName: String + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .center, spacing: 8) { + Image(systemName: systemIconName) + .font(.appTitle) + .foregroundColor(.appYellow48) + Text(title) + .font(.appBody) + .foregroundColor(.appGrayText) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + .background(Color.appButtonBackground) + .cornerRadius(8) + } + .frame(height: 100) + } +} + +struct CheckmarkButtonView: View { + let titleText: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button( + action: action, + label: { + HStack { + Text(titleText) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.appYellow48) + } + } + .padding(.vertical, 8) + } + ) + .buttonStyle(RectButtonStyle()) + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift new file mode 100644 index 000000000..4da076d77 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -0,0 +1,79 @@ +import Models +import SwiftUI +import Utils +import Views + +public class ShareExtensionViewModel: ObservableObject { + @Published var title: String? + @Published var debugText: String? + + let saveService = ExtensionSaveService() + + func handleReadNowAction(requestId: String, extensionContext: NSExtensionContext?) { + #if os(iOS) + if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication { + let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)") + application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) + } + #endif + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + + func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) { + if let extensionContext = extensionContext { + saveService.save(extensionContext, shareExtensionViewModel: shareExtensionViewModel) + } else { + DispatchQueue.main.async { + shareExtensionViewModel.status = .failed(error: .unknown(description: "Internal Error")) + } + } + } +} + +public class ShareExtensionChildViewModel: ObservableObject { + @Published public var status: ShareExtensionStatus = .processing + @Published public var title: String? + @Published public var url: String? + @Published public var iconURL: String? + @Published public var requestId: String + + public init() { + self.requestId = UUID().uuidString.lowercased() + } +} + +public enum ShareExtensionStatus { + case processing + case saved + case synced + case failed(error: SaveArticleError) + case syncFailed(error: SaveArticleError) + + var displayMessage: String { + switch self { + case .processing: + return LocalText.saveArticleProcessingState + case .saved: + return LocalText.saveArticleSavedState + case .synced: + return "Synced" + case let .failed(error: error): + return "Save failed \(error.displayMessage)" + case let .syncFailed(error: error): + return "Sync failed \(error.displayMessage)" + } + } +} + +private extension SaveArticleError { + var displayMessage: String { + switch self { + case .unauthorized: + return LocalText.extensionAppUnauthorized + case .network: + return LocalText.networkError + case .badData, .unknown: + return LocalText.genericError + } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/AsyncLoadingImage.swift b/apple/OmnivoreKit/Sources/Views/AsyncLoadingImage.swift index 2490bdc9a..b25e732aa 100644 --- a/apple/OmnivoreKit/Sources/Views/AsyncLoadingImage.swift +++ b/apple/OmnivoreKit/Sources/Views/AsyncLoadingImage.swift @@ -3,23 +3,23 @@ import Models import SwiftUI import Utils -enum AsyncImageStatus { +public enum AsyncImageStatus { case loading case loaded(image: Image) case error } -struct AsyncLoadingImage: View { +public struct AsyncLoadingImage: View { let viewBuilder: (AsyncImageStatus) -> Content let url: URL @StateObject private var imageLoader = ImageLoader() - init(url: URL, @ViewBuilder viewBuilder: @escaping (AsyncImageStatus) -> Content) { + public init(url: URL, @ViewBuilder viewBuilder: @escaping (AsyncImageStatus) -> Content) { self.url = url self.viewBuilder = viewBuilder } - var body: some View { + public var body: some View { viewBuilder(imageLoader.status) .task { await imageLoader.load(fromUrl: url) } } diff --git a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift index 6b6e974c6..443aee68c 100644 --- a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift +++ b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift @@ -42,16 +42,16 @@ public struct RoundedRectButtonStyle: ButtonStyle { } } -struct RectButtonStyle: ButtonStyle { +public struct RectButtonStyle: 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) diff --git a/apple/OmnivoreKit/Sources/Views/LocalText.swift b/apple/OmnivoreKit/Sources/Views/LocalText.swift index 861532879..9b9b3b17f 100644 --- a/apple/OmnivoreKit/Sources/Views/LocalText.swift +++ b/apple/OmnivoreKit/Sources/Views/LocalText.swift @@ -10,12 +10,12 @@ public enum LocalText { static let registrationViewSignInHeadline = localText(key: "registrationViewSignInHeadline") static let registrationViewSignUpHeadline = localText(key: "registrationViewSignUpHeadline") public static let registrationViewHeadline = localText(key: "registrationViewHeadline") - static let networkError = localText(key: "error.network") - static let genericError = localText(key: "error.generic") + public static let networkError = localText(key: "error.network") + public static let genericError = localText(key: "error.generic") static let invalidCredsLoginError = localText(key: "loginError.invalidCreds") - static let saveArticleSavedState = localText(key: "saveArticleSavedState") - static let saveArticleProcessingState = localText(key: "saveArticleProcessingState") - static let extensionAppUnauthorized = localText(key: "extensionAppUnauthorized") + public static let saveArticleSavedState = localText(key: "saveArticleSavedState") + public static let saveArticleProcessingState = localText(key: "saveArticleProcessingState") + public static let extensionAppUnauthorized = localText(key: "extensionAppUnauthorized") static let dismissButton = localText(key: "dismissButton") static let usernameValidationErrorInvalid = localText(key: "username.validation.error.invalidPattern") static let usernameValidationErrorTooShort = localText(key: "username.validation.error.tooshort") diff --git a/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift deleted file mode 100644 index fb8d32fb7..000000000 --- a/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift +++ /dev/null @@ -1,296 +0,0 @@ -import Models -import SwiftUI -import Utils - -public class ShareExtensionChildViewModel: ObservableObject { - @Published public var status: ShareExtensionStatus = .processing - @Published public var title: String? - @Published public var url: String? - @Published public var iconURL: String? - @Published public var requestId: String - - public init() { - self.requestId = UUID().uuidString.lowercased() - } -} - -public enum ShareExtensionStatus { - case processing - case saved - case synced - case failed(error: SaveArticleError) - case syncFailed(error: SaveArticleError) - - var displayMessage: String { - switch self { - case .processing: - return LocalText.saveArticleProcessingState - case .saved: - return LocalText.saveArticleSavedState - case .synced: - return "Synced" - case let .failed(error: error): - return "Save failed \(error.displayMessage)" - case let .syncFailed(error: error): - return "Sync failed \(error.displayMessage)" - } - } -} - -private extension SaveArticleError { - var displayMessage: String { - switch self { - case .unauthorized: - return LocalText.extensionAppUnauthorized - case .network: - return LocalText.networkError - case .badData, .unknown: - return LocalText.genericError - } - } -} - -struct IconButtonView: View { - let title: String - let systemIconName: String - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .center, spacing: 8) { - Image(systemName: systemIconName) - .font(.appTitle) - .foregroundColor(.appYellow48) - Text(title) - .font(.appBody) - .foregroundColor(.appGrayText) - } - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .background(Color.appButtonBackground) - .cornerRadius(8) - } - .frame(height: 100) - } -} - -struct CheckmarkButtonView: View { - let titleText: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button( - action: action, - label: { - HStack { - Text(titleText) - Spacer() - if isSelected { - Image(systemName: "checkmark") - .foregroundColor(.appYellow48) - } - } - .padding(.vertical, 8) - } - ) - .buttonStyle(RectButtonStyle()) - } -} - -public struct ShareExtensionChildView: View { - let viewModel: ShareExtensionChildViewModel - let onAppearAction: () -> Void - let readNowButtonAction: (String) -> Void - let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void - - @State var reminderTime: ReminderTime? - @State var hideUntilReminded = false - - public init( - viewModel: ShareExtensionChildViewModel, - onAppearAction: @escaping () -> Void, - readNowButtonAction: @escaping (String) -> Void, - dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void - ) { - self.viewModel = viewModel - self.onAppearAction = onAppearAction - self.readNowButtonAction = readNowButtonAction - self.dismissButtonTappedAction = dismissButtonTappedAction - } - - private func handleReminderTimeSelection(_ selectedTime: ReminderTime) { - if selectedTime == reminderTime { - reminderTime = nil - hideUntilReminded = false - } else { - reminderTime = selectedTime - hideUntilReminded = true - } - } - - private var titleText: String { - switch viewModel.status { - case .saved, .synced, .syncFailed(error: _): - return "Saved to Omnivore" - case .processing: - return "Saving to Omnivore" - case .failed(error: _): - return "Error saving to Omnivore" - } - } - - private var cloudIconName: String { - switch viewModel.status { - case .synced: - return "checkmark.icloud" - case .saved, .processing: - return "icloud" - case .failed(error: _), .syncFailed(error: _): - return "exclamationmark.icloud" - } - } - - private var cloudIconColor: Color { - switch viewModel.status { - case .saved: - return .appGrayText - case .processing: - return .clear - case .failed(error: _), .syncFailed(error: _): - return .red - case .synced: - return .blue - } - } - - private func localImage(from url: URL) -> Image? { - #if os(iOS) - if let data = try? Data(contentsOf: url), let img = UIImage(data: data) { - return Image(uiImage: img) - } - #else - if let data = try? Data(contentsOf: url), let img = NSImage(data: data) { - return Image(nsImage: img) - } - #endif - return nil - } - - public var previewCard: some View { - HStack { - if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) { - if !iconURL.isFileURL { - AsyncLoadingImage(url: iconURL) { imageStatus in - if case let AsyncImageStatus.loaded(image) = imageStatus { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 61, height: 61) - .clipped() - } else { - Color.appButtonBackground - .aspectRatio(contentMode: .fill) - .frame(width: 61, height: 61) - } - } - } else { - if let localImage = localImage(from: iconURL) { - localImage - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 61, height: 61) - .clipped() - } else { - Color.appButtonBackground - .aspectRatio(contentMode: .fill) - .frame(width: 61, height: 61) - } - } - } else { - Color.appButtonBackground - .aspectRatio(contentMode: .fill) - .frame(width: 61, height: 61) - } - - VStack(alignment: .leading) { - Text(viewModel.title ?? "") - .lineLimit(1) - .foregroundColor(.appGrayText) - .font(Font.system(size: 15, weight: .semibold)) - Text(viewModel.url ?? "") - .lineLimit(1) - .foregroundColor(.appGrayText) - .font(Font.system(size: 12, weight: .regular)) - } - Spacer() - VStack { - Spacer() - Image(systemName: cloudIconName) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 12, height: 12, alignment: .trailing) - .foregroundColor(cloudIconColor) - // .padding(.trailing, 6) - .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8)) - } - } - .background(Color.appButtonBackground) - .frame(maxWidth: .infinity, maxHeight: 61) - .cornerRadius(8) - } - - public var body: some View { - VStack(alignment: .leading) { - Text(titleText) - .foregroundColor(.appGrayText) - .font(Font.system(size: 17, weight: .semibold)) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 23) - .padding(.bottom, 12) - - Rectangle() - .foregroundColor(.appGrayText) - .frame(maxWidth: .infinity, maxHeight: 1) - .opacity(0.06) - .padding(.top, 0) - .padding(.bottom, 18) - - previewCard - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - - Spacer() - - HStack { - Button( - action: { readNowButtonAction(self.viewModel.requestId) }, - label: { Text("Read Now").frame(maxWidth: .infinity) } - ) - .buttonStyle(RoundedRectButtonStyle()) - - Button( - action: { - dismissButtonTappedAction(reminderTime, hideUntilReminded) - }, - label: { - Text("Dismiss") - .frame(maxWidth: .infinity) - } - ) - .buttonStyle(RoundedRectButtonStyle()) - } - .padding(.horizontal) - .padding(.bottom) - } - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - .onAppear { - onAppearAction() - } - } -}