Add sync progress indicator
This commit is contained in:
parent
f320311a78
commit
c41a81414d
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue