From ef72e09006f48cb90640a8bbe37cd967ab518dfd Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 2 Jun 2022 15:29:57 -0700 Subject: [PATCH] Start implementing the new share extension, mostly so we can show the sync status --- .../Share/ExtensionSaveService.swift | 49 +++- .../Share/ShareExtensionScene.swift | 19 +- .../WebReader/WebReaderLoadingContainer.swift | 2 + .../Queries/ArticleContentQuery.swift | 2 +- .../Sources/Views/ShareExtensionView.swift | 255 ++++++++++-------- .../ShareExtensionViewController.swift | 2 +- 6 files changed, 192 insertions(+), 137 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift index 258bb853a..f1ce8f5a2 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ExtensionSaveService.swift @@ -10,8 +10,6 @@ import Models import Services import Views -typealias UpdateStatusFunc = (ShareExtensionStatus) -> Void - class ExtensionSaveService { let queue: OperationQueue @@ -19,7 +17,7 @@ class ExtensionSaveService { self.queue = OperationQueue() } - private func queueSaveOperation(_ pageScrape: PageScrapePayload, updateStatusFunc: UpdateStatusFunc?) { + private func queueSaveOperation(_ pageScrape: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) { ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in guard !expiring else { self.queue.cancelAllOperations() @@ -27,22 +25,47 @@ class ExtensionSaveService { return } - let operation = SaveOperation(pageScrapePayload: pageScrape, updateStatusFunc: updateStatusFunc) + let operation = SaveOperation(pageScrapePayload: pageScrape, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel) self.queue.addOperation(operation) self.queue.waitUntilAllOperationsAreFinished() } } - public func save(_ extensionContext: NSExtensionContext, updateStatusFunc: UpdateStatusFunc?) { + public func save(_ extensionContext: NSExtensionContext, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) { PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in guard let self = self else { return } switch result { case let .success(payload): - self.queueSaveOperation(payload, updateStatusFunc: updateStatusFunc) + DispatchQueue.main.async { + shareExtensionViewModel.status = .saved + + let url = URLComponents(string: payload.url) + let hostname = URL(string: payload.url)?.host ?? "" + + switch payload.contentType { + case let .html(html: _, title: title, iconURL: iconURL): + shareExtensionViewModel.title = title + shareExtensionViewModel.iconURL = iconURL + shareExtensionViewModel.url = hostname + case .none: + shareExtensionViewModel.url = hostname + shareExtensionViewModel.title = "Saving: " + payload.url + if var url = url { + url.path = "/favicon.ico" + shareExtensionViewModel.iconURL = url.url?.absoluteString + } + case let .pdf(localUrl: _): + shareExtensionViewModel.title = "Saving: " + payload.url + shareExtensionViewModel.url = hostname + } + } + self.queueSaveOperation(payload, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel) case let .failure(error): - print("failed", error) + DispatchQueue.main.async { + shareExtensionViewModel.status = .failed(error: .unknown(description: "Could not retrieve content")) + } } } } @@ -51,7 +74,7 @@ class ExtensionSaveService { let requestId: String let services: Services let pageScrapePayload: PageScrapePayload - let updateStatusFunc: UpdateStatusFunc? + let shareExtensionViewModel: ShareExtensionChildViewModel var queue: OperationQueue? var uploadTask: URLSessionTask? @@ -62,13 +85,13 @@ class ExtensionSaveService { case finished } - init(pageScrapePayload: PageScrapePayload, updateStatusFunc: UpdateStatusFunc? = nil) { + init(pageScrapePayload: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) { self.pageScrapePayload = pageScrapePayload - self.updateStatusFunc = updateStatusFunc + self.requestId = requestId + self.shareExtensionViewModel = shareExtensionViewModel self.state = .created self.services = Services() - self.requestId = UUID().uuidString.lowercased() } open var state: State = .created { @@ -118,9 +141,7 @@ class ExtensionSaveService { private func updateStatus(newStatus: ShareExtensionStatus) { DispatchQueue.main.async { - if let updateStatusFunc = self.updateStatusFunc { - updateStatusFunc(newStatus) - } + self.shareExtensionViewModel.status = newStatus } } diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift index f5b86535c..a5ea021e1 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift @@ -20,29 +20,27 @@ public extension PlatformViewController { } } -final class ShareExtensionViewModel: ObservableObject { +public class ShareExtensionViewModel: ObservableObject { @Published var title: String? @Published var status: ShareExtensionStatus = .processing @Published var debugText: String? - var subscriptions = Set() - var backgroundTask: UIBackgroundTaskIdentifier? - let requestID = UUID().uuidString.lowercased() let saveService = ExtensionSaveService() + let requestId = UUID().uuidString.lowercased() func handleReadNowAction(extensionContext: NSExtensionContext?) { #if os(iOS) if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication { - let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestID)") + let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)") application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) } #endif extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } - func savePage(extensionContext: NSExtensionContext?) { + func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) { if let extensionContext = extensionContext { - saveService.save(extensionContext, updateStatusFunc: updateStatus) + saveService.save(extensionContext, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel) } else { updateStatus(.failed(error: .unknown(description: "Internal Error"))) } @@ -58,13 +56,12 @@ final class ShareExtensionViewModel: ObservableObject { struct ShareExtensionView: View { let extensionContext: NSExtensionContext? @StateObject private var viewModel = ShareExtensionViewModel() + @StateObject private var childViewModel = ShareExtensionChildViewModel() var body: some View { ShareExtensionChildView( - debugText: viewModel.debugText, - title: viewModel.title, - status: viewModel.status, - onAppearAction: { viewModel.savePage(extensionContext: extensionContext) }, + viewModel: childViewModel, + onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) }, readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) }, dismissButtonTappedAction: { _, _ in extensionContext?.completeRequest(returningItems: [], completionHandler: nil) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index 087be99c0..046d0673a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -24,6 +24,8 @@ import Utils guard let username = username else { return } + // If the page was locally created, make sure they are synced before we pull content + await dataService.syncUnsyncedArticleContent(itemID: requestID) await fetchLinkedItem(dataService: dataService, requestID: requestID, username: username) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index c67f7e43c..29aca5822 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -308,7 +308,7 @@ extension DataService { } } - func syncUnsyncedArticleContent(itemID: String) async { + public func syncUnsyncedArticleContent(itemID: String) async { let linkedItemFetchRequest: NSFetchRequest = LinkedItem.fetchRequest() linkedItemFetchRequest.predicate = NSPredicate( format: "id == %@", itemID diff --git a/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift index acc4bd060..21b318978 100644 --- a/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/Views/ShareExtensionView.swift @@ -2,6 +2,15 @@ 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? + + public init() {} +} + public enum ShareExtensionStatus { case processing case saved @@ -25,6 +34,32 @@ public enum ShareExtensionStatus { } } +struct CornerRadiusStyle: ViewModifier { + var radius: CGFloat + var corners: UIRectCorner + + struct CornerRadiusShape: Shape { + var radius = CGFloat.infinity + var corners = UIRectCorner.allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } + } + + func body(content: Content) -> some View { + content + .clipShape(CornerRadiusShape(radius: radius, corners: corners)) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + ModifiedContent(content: self, modifier: CornerRadiusStyle(radius: radius, corners: corners)) + } +} + private extension SaveArticleError { var displayMessage: String { switch self { @@ -89,32 +124,26 @@ struct CheckmarkButtonView: View { } public struct ShareExtensionChildView: View { - let debugText: String? - let title: String? - let status: ShareExtensionStatus + let viewModel: ShareExtensionChildViewModel let onAppearAction: () -> Void let readNowButtonAction: () -> Void let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void + @State var reminderTime: ReminderTime? + @State var hideUntilReminded = false + public init( - debugText: String?, - title: String?, - status: ShareExtensionStatus, + viewModel: ShareExtensionChildViewModel, onAppearAction: @escaping () -> Void, readNowButtonAction: @escaping () -> Void, dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void ) { - self.debugText = debugText - self.title = title - self.status = status + self.viewModel = viewModel self.onAppearAction = onAppearAction self.readNowButtonAction = readNowButtonAction self.dismissButtonTappedAction = dismissButtonTappedAction } - @State var reminderTime: ReminderTime? - @State var hideUntilReminded = false - private func handleReminderTimeSelection(_ selectedTime: ReminderTime) { if selectedTime == reminderTime { reminderTime = nil @@ -125,111 +154,117 @@ public struct ShareExtensionChildView: View { } } + private var titleText: String { + switch viewModel.status { + case .saved, .synced: + return "Saved to Omnivore" + case .processing: + return "Saving to Omnivore" + default: + 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, .processing: + return .appGrayText + case .failed(error: _), .syncFailed(error: _): + return .red + case .synced: + return .blue + } + } + + public var previewCard: some View { + HStack { + if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) { + AsyncLoadingImage(url: iconURL) { imageStatus in + if case let AsyncImageStatus.loaded(image) = imageStatus { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + } else if case AsyncImageStatus.loading = imageStatus { + Color.appButtonBackground + .aspectRatio(contentMode: .fill) + .frame(width: 61, height: 61) + } else { + EmptyView() + } + } + } else { + EmptyView() + .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(hex: "#363636")) + .frame(maxWidth: .infinity, maxHeight: 61) + .cornerRadius(8) + } + public var body: some View { VStack(alignment: .leading) { - #if DEBUG - if let debugText = debugText { - Text(debugText) - } - #endif + Text(titleText) + .foregroundColor(.appGrayText) + .font(Font.system(size: 17, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 23) + .padding(.bottom, 16) - if let title = title { - Text(title) - .font(.appHeadline) - .lineLimit(1) - .padding(.trailing, 50) - Divider() - } + Rectangle() + .foregroundColor(.appGrayText) + .frame(maxWidth: .infinity, maxHeight: 1) + .opacity(0.06) + .padding(.top, 0) + .padding(.bottom, 16) + + previewCard + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) Spacer() - switch status { - case .processing: - HStack { - Spacer() - Text("Saving...") - Spacer() - } - case .saved: - HStack { - Spacer() - Text("Syncing...") - Spacer() - } - case .synced: - HStack(spacing: 4) { - Text("Saved to Omnivore") - .font(.appTitleThree) - .foregroundColor(.appGrayText) - .padding(.trailing, 16) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(nil) - } - .padding() - case let .failed(error: error): - HStack { - Spacer() - Text("Failed to save:" + error.displayMessage) - Spacer() - } - case let .syncFailed(error: error): - HStack { - Spacer() - Text("Failed to sync:" + error.displayMessage) - Spacer() - } - } - - ScrollView { - if FeatureFlag.enableRemindersFromShareExtension { - VStack(spacing: 0) { - CheckmarkButtonView( - titleText: "Remind me tonight", - isSelected: reminderTime == .tonight, - action: { handleReminderTimeSelection(.tonight) } - ) - - Divider() - - CheckmarkButtonView( - titleText: "Remind me tomorrow", - isSelected: reminderTime == .tomorrow, - action: { handleReminderTimeSelection(.tomorrow) } - ) - - Divider() - - CheckmarkButtonView( - titleText: "Remind me this weekend", - isSelected: reminderTime == .thisWeekend, - action: { handleReminderTimeSelection(.thisWeekend) } - ) - } - .cornerRadius(8) - } - - if FeatureFlag.enableSnoozeFromShareExtension { - CheckmarkButtonView( - titleText: "Hide it until then", - isSelected: hideUntilReminded, - action: { hideUntilReminded.toggle() } - ) - .cornerRadius(8) - .padding(.top, 16) - } - } - .padding(.horizontal) - HStack { - if case ShareExtensionStatus.saved = status, FeatureFlag.enableReadNow { - Button( - action: { readNowButtonAction() }, - label: { Text("Read Now").frame(maxWidth: .infinity) } - ) - .buttonStyle(RoundedRectButtonStyle()) - } - if case ShareExtensionStatus.processing = status, FeatureFlag.enableReadNow { + // if case ShareExtensionStatus.saved = status || case ShareExtensionStatus.synced = status { + Button( + action: { readNowButtonAction() }, + label: { Text("Read Now").frame(maxWidth: .infinity) } + ) + .buttonStyle(RoundedRectButtonStyle()) + // } + if case ShareExtensionStatus.processing = viewModel.status, FeatureFlag.enableReadNow { Button(action: {}, label: { ProgressView().frame(maxWidth: .infinity) }) .buttonStyle(RoundedRectButtonStyle()) } diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index ba45041e6..9b118b175 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -12,7 +12,7 @@ import Utils embed( childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext), - heightRatio: 0.3 + heightRatio: 0.5 ) } }