Start implementing the new share extension, mostly so we can show the sync status

This commit is contained in:
Jackson Harper
2022-06-02 15:29:57 -07:00
parent 92f8218960
commit ef72e09006
6 changed files with 192 additions and 137 deletions

View File

@ -10,8 +10,6 @@ import Models
import Services import Services
import Views import Views
typealias UpdateStatusFunc = (ShareExtensionStatus) -> Void
class ExtensionSaveService { class ExtensionSaveService {
let queue: OperationQueue let queue: OperationQueue
@ -19,7 +17,7 @@ class ExtensionSaveService {
self.queue = OperationQueue() 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 ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
guard !expiring else { guard !expiring else {
self.queue.cancelAllOperations() self.queue.cancelAllOperations()
@ -27,22 +25,47 @@ class ExtensionSaveService {
return return
} }
let operation = SaveOperation(pageScrapePayload: pageScrape, updateStatusFunc: updateStatusFunc) let operation = SaveOperation(pageScrapePayload: pageScrape, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
self.queue.addOperation(operation) self.queue.addOperation(operation)
self.queue.waitUntilAllOperationsAreFinished() 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 PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
case let .success(payload): 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): 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 requestId: String
let services: Services let services: Services
let pageScrapePayload: PageScrapePayload let pageScrapePayload: PageScrapePayload
let updateStatusFunc: UpdateStatusFunc? let shareExtensionViewModel: ShareExtensionChildViewModel
var queue: OperationQueue? var queue: OperationQueue?
var uploadTask: URLSessionTask? var uploadTask: URLSessionTask?
@ -62,13 +85,13 @@ class ExtensionSaveService {
case finished case finished
} }
init(pageScrapePayload: PageScrapePayload, updateStatusFunc: UpdateStatusFunc? = nil) { init(pageScrapePayload: PageScrapePayload, requestId: String, shareExtensionViewModel: ShareExtensionChildViewModel) {
self.pageScrapePayload = pageScrapePayload self.pageScrapePayload = pageScrapePayload
self.updateStatusFunc = updateStatusFunc self.requestId = requestId
self.shareExtensionViewModel = shareExtensionViewModel
self.state = .created self.state = .created
self.services = Services() self.services = Services()
self.requestId = UUID().uuidString.lowercased()
} }
open var state: State = .created { open var state: State = .created {
@ -118,9 +141,7 @@ class ExtensionSaveService {
private func updateStatus(newStatus: ShareExtensionStatus) { private func updateStatus(newStatus: ShareExtensionStatus) {
DispatchQueue.main.async { DispatchQueue.main.async {
if let updateStatusFunc = self.updateStatusFunc { self.shareExtensionViewModel.status = newStatus
updateStatusFunc(newStatus)
}
} }
} }

View File

@ -20,29 +20,27 @@ public extension PlatformViewController {
} }
} }
final class ShareExtensionViewModel: ObservableObject { public class ShareExtensionViewModel: ObservableObject {
@Published var title: String? @Published var title: String?
@Published var status: ShareExtensionStatus = .processing @Published var status: ShareExtensionStatus = .processing
@Published var debugText: String? @Published var debugText: String?
var subscriptions = Set<AnyCancellable>()
var backgroundTask: UIBackgroundTaskIdentifier?
let requestID = UUID().uuidString.lowercased()
let saveService = ExtensionSaveService() let saveService = ExtensionSaveService()
let requestId = UUID().uuidString.lowercased()
func handleReadNowAction(extensionContext: NSExtensionContext?) { func handleReadNowAction(extensionContext: NSExtensionContext?) {
#if os(iOS) #if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication { 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) application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
} }
#endif #endif
extensionContext?.completeRequest(returningItems: [], completionHandler: nil) extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
func savePage(extensionContext: NSExtensionContext?) { func savePage(extensionContext: NSExtensionContext?, shareExtensionViewModel: ShareExtensionChildViewModel) {
if let extensionContext = extensionContext { if let extensionContext = extensionContext {
saveService.save(extensionContext, updateStatusFunc: updateStatus) saveService.save(extensionContext, requestId: requestId, shareExtensionViewModel: shareExtensionViewModel)
} else { } else {
updateStatus(.failed(error: .unknown(description: "Internal Error"))) updateStatus(.failed(error: .unknown(description: "Internal Error")))
} }
@ -58,13 +56,12 @@ final class ShareExtensionViewModel: ObservableObject {
struct ShareExtensionView: View { struct ShareExtensionView: View {
let extensionContext: NSExtensionContext? let extensionContext: NSExtensionContext?
@StateObject private var viewModel = ShareExtensionViewModel() @StateObject private var viewModel = ShareExtensionViewModel()
@StateObject private var childViewModel = ShareExtensionChildViewModel()
var body: some View { var body: some View {
ShareExtensionChildView( ShareExtensionChildView(
debugText: viewModel.debugText, viewModel: childViewModel,
title: viewModel.title, onAppearAction: { viewModel.savePage(extensionContext: extensionContext, shareExtensionViewModel: childViewModel) },
status: viewModel.status,
onAppearAction: { viewModel.savePage(extensionContext: extensionContext) },
readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) }, readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
dismissButtonTappedAction: { _, _ in dismissButtonTappedAction: { _, _ in
extensionContext?.completeRequest(returningItems: [], completionHandler: nil) extensionContext?.completeRequest(returningItems: [], completionHandler: nil)

View File

@ -24,6 +24,8 @@ import Utils
guard let username = username else { return } 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) await fetchLinkedItem(dataService: dataService, requestID: requestID, username: username)
} }

View File

@ -308,7 +308,7 @@ extension DataService {
} }
} }
func syncUnsyncedArticleContent(itemID: String) async { public func syncUnsyncedArticleContent(itemID: String) async {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest() let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate( linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID format: "id == %@", itemID

View File

@ -2,6 +2,15 @@ import Models
import SwiftUI import SwiftUI
import Utils 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 { public enum ShareExtensionStatus {
case processing case processing
case saved 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 { private extension SaveArticleError {
var displayMessage: String { var displayMessage: String {
switch self { switch self {
@ -89,32 +124,26 @@ struct CheckmarkButtonView: View {
} }
public struct ShareExtensionChildView: View { public struct ShareExtensionChildView: View {
let debugText: String? let viewModel: ShareExtensionChildViewModel
let title: String?
let status: ShareExtensionStatus
let onAppearAction: () -> Void let onAppearAction: () -> Void
let readNowButtonAction: () -> Void let readNowButtonAction: () -> Void
let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void
@State var reminderTime: ReminderTime?
@State var hideUntilReminded = false
public init( public init(
debugText: String?, viewModel: ShareExtensionChildViewModel,
title: String?,
status: ShareExtensionStatus,
onAppearAction: @escaping () -> Void, onAppearAction: @escaping () -> Void,
readNowButtonAction: @escaping () -> Void, readNowButtonAction: @escaping () -> Void,
dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void
) { ) {
self.debugText = debugText self.viewModel = viewModel
self.title = title
self.status = status
self.onAppearAction = onAppearAction self.onAppearAction = onAppearAction
self.readNowButtonAction = readNowButtonAction self.readNowButtonAction = readNowButtonAction
self.dismissButtonTappedAction = dismissButtonTappedAction self.dismissButtonTappedAction = dismissButtonTappedAction
} }
@State var reminderTime: ReminderTime?
@State var hideUntilReminded = false
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) { private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
if selectedTime == reminderTime { if selectedTime == reminderTime {
reminderTime = nil 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 { public var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
#if DEBUG Text(titleText)
if let debugText = debugText { .foregroundColor(.appGrayText)
Text(debugText) .font(Font.system(size: 17, weight: .semibold))
} .frame(maxWidth: .infinity, alignment: .center)
#endif .padding(.top, 23)
.padding(.bottom, 16)
if let title = title { Rectangle()
Text(title) .foregroundColor(.appGrayText)
.font(.appHeadline) .frame(maxWidth: .infinity, maxHeight: 1)
.lineLimit(1) .opacity(0.06)
.padding(.trailing, 50) .padding(.top, 0)
Divider() .padding(.bottom, 16)
}
previewCard
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
Spacer() 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 { HStack {
if case ShareExtensionStatus.saved = status, FeatureFlag.enableReadNow { // if case ShareExtensionStatus.saved = status || case ShareExtensionStatus.synced = status {
Button( Button(
action: { readNowButtonAction() }, action: { readNowButtonAction() },
label: { Text("Read Now").frame(maxWidth: .infinity) } label: { Text("Read Now").frame(maxWidth: .infinity) }
) )
.buttonStyle(RoundedRectButtonStyle()) .buttonStyle(RoundedRectButtonStyle())
} // }
if case ShareExtensionStatus.processing = status, FeatureFlag.enableReadNow { if case ShareExtensionStatus.processing = viewModel.status, FeatureFlag.enableReadNow {
Button(action: {}, label: { ProgressView().frame(maxWidth: .infinity) }) Button(action: {}, label: { ProgressView().frame(maxWidth: .infinity) })
.buttonStyle(RoundedRectButtonStyle()) .buttonStyle(RoundedRectButtonStyle())
} }

View File

@ -12,7 +12,7 @@ import Utils
embed( embed(
childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext), childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext),
heightRatio: 0.3 heightRatio: 0.5
) )
} }
} }