Add sync progress indicator

This commit is contained in:
Shadowfacts 2022-01-26 22:37:10 -05:00
parent f320311a78
commit c41a81414d
5 changed files with 135 additions and 8 deletions

View File

@ -102,7 +102,7 @@ class PersistentContainer: NSPersistentContainer {
try await self.saveViewContext() 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 { try await backgroundContext.perform {
self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items") self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items")
let deleteReq = Item.fetchRequest() let deleteReq = Item.fetchRequest()
@ -122,7 +122,8 @@ class PersistentContainer: NSPersistentContainer {
let existing = try self.backgroundContext.fetch(req) 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)") 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 // 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 }) { if let existing = existing.first(where: { $0.id == item.id }) {
existing.updateFromServer(item) existing.updateFromServer(item)
} else { } else {

View File

@ -15,12 +15,13 @@ public struct ExcerptGenerator {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "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() let req = Item.fetchRequest()
req.predicate = NSPredicate(format: "generatedExcerpt = NO") req.predicate = NSPredicate(format: "generatedExcerpt = NO")
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
req.fetchBatchSize = 50 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 } guard let items = try? ctx.fetch(req) else { return }
var count = 0 var count = 0
for item in items { for item in items {

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import Fervor import Fervor
import OSLog import OSLog
import Combine
class FervorController { class FervorController {
@ -25,6 +26,8 @@ class FervorController {
private(set) var persistentContainer: PersistentContainer! private(set) var persistentContainer: PersistentContainer!
let syncStateSubject = PassthroughSubject<SyncState, Never>()
init(instanceURL: URL) { init(instanceURL: URL) {
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil) self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
@ -42,6 +45,12 @@ class FervorController {
self.persistentContainer = PersistentContainer(account: account, fervorController: self) self.persistentContainer = PersistentContainer(account: account, fervorController: self)
} }
private func setSyncState(_ state: SyncState) {
DispatchQueue.main.async {
self.syncStateSubject.send(state)
}
}
func register() async throws -> ClientRegistration { func register() async throws -> ClientRegistration {
let registration = try await client.register(clientName: "Frenzy iOS", website: nil, redirectURI: FervorController.oauthRedirectURI) let registration = try await client.register(clientName: "Frenzy iOS", website: nil, redirectURI: FervorController.oauthRedirectURI)
clientID = registration.clientID clientID = registration.clientID
@ -56,16 +65,25 @@ class FervorController {
} }
func syncAll() async throws { func syncAll() async throws {
setSyncState(.groupsAndFeeds)
logger.info("Syncing groups and feeds") logger.info("Syncing groups and feeds")
async let groups = try client.groups() async let groups = try client.groups()
async let feeds = try client.feeds() async let feeds = try client.feeds()
try await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds) try await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds)
setSyncState(.items)
let lastSync = try await persistentContainer.lastSyncDate() let lastSync = try await persistentContainer.lastSyncDate()
logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)") logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)")
let update = try await client.syncItems(lastSync: lastSync) 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) try await persistentContainer.updateLastSyncDate(update.syncTimestamp)
setSyncState(.excerpts)
await ExcerptGenerator.generateAll(self)
setSyncState(.done)
} }
@MainActor @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
}
}

View File

@ -111,12 +111,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
Task(priority: .userInitiated) { Task(priority: .userInitiated) {
do { do {
try await self.fervorController.syncAll() try await fervorController.syncAll()
} catch { } catch {
logger.error("Unable to sync from server: \(String(describing: error), privacy: .public)") logger.error("Unable to sync from server: \(String(describing: error), privacy: .public)")
} }
ExcerptGenerator.generateAll(fervorController)
} }
} }

View File

@ -7,6 +7,7 @@
import UIKit import UIKit
import CoreData import CoreData
import Combine
protocol HomeViewControllerDelegate: AnyObject { protocol HomeViewControllerDelegate: AnyObject {
func switchToAccount(_ account: LocalData.Account) func switchToAccount(_ account: LocalData.Account)
@ -26,6 +27,10 @@ class HomeViewController: UIViewController {
private var groupResultsController: NSFetchedResultsController<Group>! private var groupResultsController: NSFetchedResultsController<Group>!
private var feedResultsController: NSFetchedResultsController<Feed>! private var feedResultsController: NSFetchedResultsController<Feed>!
private var lastSyncState = FervorController.SyncState.done
private var syncStateView: SyncStateView?
private var cancellables = Set<AnyCancellable>()
init(fervorController: FervorController) { init(fervorController: FervorController) {
self.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 = NSFetchedResultsController(fetchRequest: feedReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
feedResultsController.delegate = self feedResultsController.delegate = self
try! feedResultsController.performFetch() 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) { override func viewWillAppear(_ animated: Bool) {
@ -136,6 +149,56 @@ class HomeViewController: UIViewController {
return dataSource 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 { extension HomeViewController {
@ -307,3 +370,39 @@ extension HomeViewController: LoginViewControllerDelegate {
(view.window!.windowScene!.delegate as! SceneDelegate).didLogin(with: controller) (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")
}
}