diff --git a/Reader/CoreData/PersistentContainer.swift b/Reader/CoreData/PersistentContainer.swift index 0ef2d68..07472e0 100644 --- a/Reader/CoreData/PersistentContainer.swift +++ b/Reader/CoreData/PersistentContainer.swift @@ -102,7 +102,7 @@ class PersistentContainer: NSPersistentContainer { try await self.saveViewContext() } - func syncItems(_ syncUpdate: ItemsSyncUpdate) async throws { + func syncItems(_ syncUpdate: ItemsSyncUpdate, setSyncState: @escaping (FervorController.SyncState) -> Void) async throws { try await backgroundContext.perform { self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items") let deleteReq = Item.fetchRequest() @@ -122,7 +122,8 @@ class PersistentContainer: NSPersistentContainer { let existing = try self.backgroundContext.fetch(req) self.logger.debug("syncItems: updating \(existing.count, privacy: .public) items, inserting \(syncUpdate.upsert.count - existing.count, privacy: .public)") // todo: this feels like it'll be slow when there are many items - for item in syncUpdate.upsert { + for (index, item) in syncUpdate.upsert.enumerated() { + setSyncState(.updateItems(current: index, total: syncUpdate.upsert.count)) if let existing = existing.first(where: { $0.id == item.id }) { existing.updateFromServer(item) } else { diff --git a/Reader/ExcerptGenerator.swift b/Reader/ExcerptGenerator.swift index 4f26a7c..2f0dfe4 100644 --- a/Reader/ExcerptGenerator.swift +++ b/Reader/ExcerptGenerator.swift @@ -15,12 +15,13 @@ public struct ExcerptGenerator { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ExcerptGenerator") - static func generateAll(_ fervorController: FervorController) { + static func generateAll(_ fervorController: FervorController) async { let req = Item.fetchRequest() req.predicate = NSPredicate(format: "generatedExcerpt = NO") req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] req.fetchBatchSize = 50 - fervorController.persistentContainer.performBackgroundTask { ctx in + let ctx = fervorController.persistentContainer.newBackgroundContext() + await ctx.perform { guard let items = try? ctx.fetch(req) else { return } var count = 0 for item in items { diff --git a/Reader/FervorController.swift b/Reader/FervorController.swift index 7f31afe..823e67d 100644 --- a/Reader/FervorController.swift +++ b/Reader/FervorController.swift @@ -8,6 +8,7 @@ import Foundation import Fervor import OSLog +import Combine class FervorController { @@ -25,6 +26,8 @@ class FervorController { private(set) var persistentContainer: PersistentContainer! + let syncStateSubject = PassthroughSubject() + init(instanceURL: URL) { self.instanceURL = instanceURL self.client = FervorClient(instanceURL: instanceURL, accessToken: nil) @@ -42,6 +45,12 @@ class FervorController { self.persistentContainer = PersistentContainer(account: account, fervorController: self) } + private func setSyncState(_ state: SyncState) { + DispatchQueue.main.async { + self.syncStateSubject.send(state) + } + } + func register() async throws -> ClientRegistration { let registration = try await client.register(clientName: "Frenzy iOS", website: nil, redirectURI: FervorController.oauthRedirectURI) clientID = registration.clientID @@ -56,16 +65,25 @@ class FervorController { } func syncAll() async throws { + setSyncState(.groupsAndFeeds) + logger.info("Syncing groups and feeds") async let groups = try client.groups() async let feeds = try client.feeds() try await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds) + setSyncState(.items) + let lastSync = try await persistentContainer.lastSyncDate() logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)") let update = try await client.syncItems(lastSync: lastSync) - try await persistentContainer.syncItems(update) + try await persistentContainer.syncItems(update, setSyncState: setSyncState(_:)) try await persistentContainer.updateLastSyncDate(update.syncTimestamp) + + setSyncState(.excerpts) + await ExcerptGenerator.generateAll(self) + + setSyncState(.done) } @MainActor @@ -132,3 +150,13 @@ class FervorController { } } + +extension FervorController { + enum SyncState: Equatable { + case groupsAndFeeds + case items + case updateItems(current: Int, total: Int) + case excerpts + case done + } +} diff --git a/Reader/SceneDelegate.swift b/Reader/SceneDelegate.swift index 8b32c5e..18abaa5 100644 --- a/Reader/SceneDelegate.swift +++ b/Reader/SceneDelegate.swift @@ -111,12 +111,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } Task(priority: .userInitiated) { do { - try await self.fervorController.syncAll() + try await fervorController.syncAll() } catch { logger.error("Unable to sync from server: \(String(describing: error), privacy: .public)") } - - ExcerptGenerator.generateAll(fervorController) } } diff --git a/Reader/Screens/Home/HomeViewController.swift b/Reader/Screens/Home/HomeViewController.swift index 33e3304..992f590 100644 --- a/Reader/Screens/Home/HomeViewController.swift +++ b/Reader/Screens/Home/HomeViewController.swift @@ -7,6 +7,7 @@ import UIKit import CoreData +import Combine protocol HomeViewControllerDelegate: AnyObject { func switchToAccount(_ account: LocalData.Account) @@ -26,6 +27,10 @@ class HomeViewController: UIViewController { private var groupResultsController: NSFetchedResultsController! private var feedResultsController: NSFetchedResultsController! + private var lastSyncState = FervorController.SyncState.done + private var syncStateView: SyncStateView? + private var cancellables = Set() + init(fervorController: FervorController) { self.fervorController = fervorController @@ -87,6 +92,14 @@ class HomeViewController: UIViewController { feedResultsController = NSFetchedResultsController(fetchRequest: feedReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil) feedResultsController.delegate = self try! feedResultsController.performFetch() + + fervorController.syncStateSubject + .delay(for: .milliseconds(500), tolerance: nil, scheduler: RunLoop.main, options: nil) + .debounce(for: .milliseconds(250), scheduler: RunLoop.main, options: nil) + .sink { [unowned self] in + self.syncStateChanged($0) + } + .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { @@ -136,6 +149,56 @@ class HomeViewController: UIViewController { return dataSource } + private func syncStateChanged(_ newState: FervorController.SyncState) { + if newState == .done && syncStateView == nil { + return + } + + func updateView(_ syncStateView: SyncStateView) { + switch newState { + case .groupsAndFeeds: + syncStateView.label.text = "Syncing groups and feeds" + case .items: + syncStateView.label.text = "Syncing items" + case .updateItems(current: let current, total: let total): + syncStateView.label.text = "Updating \(current + 1) of \(total) item\(total == 1 ? "" : "s")" + case .excerpts: + syncStateView.label.text = "Generating excerpts" + case .done: + syncStateView.label.text = "Done syncing" + + UIView.animate(withDuration: 0.25, delay: 1, options: .curveEaseIn) { + syncStateView.transform = CGAffineTransform(translationX: 0, y: syncStateView.bounds.height) + } completion: { _ in + syncStateView.removeFromSuperview() + } + } + } + + if let syncStateView = self.syncStateView { + updateView(syncStateView) + } else { + let syncStateView = SyncStateView() + self.syncStateView = syncStateView + syncStateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(syncStateView) + NSLayoutConstraint.activate([ + syncStateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + syncStateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + syncStateView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + view.layoutIfNeeded() + syncStateView.transform = CGAffineTransform(translationX: 0, y: syncStateView.bounds.height) + + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { + syncStateView.transform = .identity + } completion: { _ in + updateView(syncStateView) + } + } + } + } extension HomeViewController { @@ -307,3 +370,39 @@ extension HomeViewController: LoginViewControllerDelegate { (view.window!.windowScene!.delegate as! SceneDelegate).didLogin(with: controller) } } + +private class SyncStateView: UIView { + let label = UILabel() + + init() { + super.init(frame: .zero) + + let blur = UIBlurEffect(style: .regular) + let blurView = UIVisualEffectView(effect: blur) + blurView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurView) + + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + + NSLayoutConstraint.activate([ + blurView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurView.topAnchor.constraint(equalTo: topAnchor), + blurView.bottomAnchor.constraint(equalTo: bottomAnchor), + + label.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + label.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor), + + safeAreaLayoutGuide.heightAnchor.constraint(equalToConstant: 50), + ]) + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.1 + layer.shadowRadius = 10 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +}