diff --git a/Delta/Base.lproj/Settings.storyboard b/Delta/Base.lproj/Settings.storyboard index ce5cc00..5310b17 100644 --- a/Delta/Base.lproj/Settings.storyboard +++ b/Delta/Base.lproj/Settings.storyboard @@ -278,11 +278,11 @@ - + + + + @@ -1086,9 +1088,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Delta/Settings/Syncing/RecordSyncStatusViewController.swift b/Delta/Settings/Syncing/RecordSyncStatusViewController.swift index 4f8be8e..efb7810 100644 --- a/Delta/Settings/Syncing/RecordSyncStatusViewController.swift +++ b/Delta/Settings/Syncing/RecordSyncStatusViewController.swift @@ -64,6 +64,23 @@ class RecordSyncStatusViewController: UITableViewController } } +extension RecordSyncStatusViewController +{ + override func prepare(for segue: UIStoryboardSegue, sender: Any?) + { + guard segue.identifier == "showVersions" else { return } + + let navigationController = segue.destination as! UINavigationController + + let recordVersionsViewController = navigationController.viewControllers[0] as! RecordVersionsViewController + recordVersionsViewController.record = self.record + } + + @IBAction private func unwindToRecordSyncStatusViewController(_ segue: UIStoryboardSegue) + { + } +} + private extension RecordSyncStatusViewController { func update() diff --git a/Delta/Settings/Syncing/RecordVersionsViewController.swift b/Delta/Settings/Syncing/RecordVersionsViewController.swift new file mode 100644 index 0000000..f3622b0 --- /dev/null +++ b/Delta/Settings/Syncing/RecordVersionsViewController.swift @@ -0,0 +1,394 @@ +// +// RecordVersionsViewController.swift +// Delta +// +// Created by Riley Testut on 11/20/18. +// Copyright © 2018 Riley Testut. All rights reserved. +// + +import UIKit + +import Roxas +import Harmony + +extension RecordVersionsViewController +{ + private enum Section: Int, CaseIterable + { + case local + case remote + case confirm + } + + private enum Mode + { + case restoreVersion + case resolveConflict + } +} + +private class Version +{ + let version: Harmony.Version + + init(_ version: Harmony.Version) + { + self.version = version + } +} + +class RecordVersionsViewController: UITableViewController +{ + var record: Record! { + didSet { + self.mode = self.record.isConflicted ? .resolveConflict : .restoreVersion + } + } + + private var mode = Mode.restoreVersion { + didSet { + switch self.mode + { + case .restoreVersion: self._selectedVersionIndexPath = IndexPath(item: 0, section: Section.local.rawValue) + case .resolveConflict: self._selectedVersionIndexPath = nil + } + } + } + + private var versions: [Version]? + + private lazy var dataSource = self.makeDataSource() + private var remoteVersionsDataSource: RSTArrayTableViewDataSource { + let compositeDataSource = self.dataSource.dataSources[1] as! RSTCompositeTableViewDataSource + + let dataSource = compositeDataSource.dataSources[1] as! RSTArrayTableViewDataSource + return dataSource + } + + private let dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .short + dateFormatter.dateStyle = .short + + return dateFormatter + }() + + private var isSyncingRecord = false + private var _selectedVersionIndexPath: IndexPath? + + private var progressView: UIProgressView! + + private var _progressObservation: NSKeyValueObservation? + + override func viewDidLoad() + { + super.viewDidLoad() + + self.progressView = UIProgressView(progressViewStyle: .bar) + self.progressView.translatesAutoresizingMaskIntoConstraints = false + self.progressView.progress = 0 + + if let navigationBar = self.navigationController?.navigationBar + { + navigationBar.addSubview(self.progressView) + + NSLayoutConstraint.activate([self.progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor), + self.progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), + self.progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)]) + } + + self.tableView.dataSource = self.dataSource + } + + override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + + self.fetchVersions() + } +} + +private extension RecordVersionsViewController +{ + func makeDataSource() -> RSTCompositeTableViewDataSource + { + func configure(_ cell: UITableViewCell, isSelected: Bool, isEnabled: Bool) + { + cell.accessoryType = isSelected ? .checkmark : .none + + if isEnabled + { + cell.textLabel?.alpha = 1.0 + cell.detailTextLabel?.alpha = 1.0 + cell.selectionStyle = .gray + } + else + { + cell.textLabel?.alpha = 0.33 + cell.detailTextLabel?.alpha = 0.33 + cell.selectionStyle = .none + } + } + + let localVersionsDataSource = RSTDynamicTableViewDataSource() + localVersionsDataSource.numberOfSectionsHandler = { 1 } + localVersionsDataSource.numberOfItemsHandler = { _ in self.record.localModificationDate != nil ? 1 : 0 } + localVersionsDataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in + guard let `self` = self else { return } + + let date = self.record.localModificationDate! + cell.textLabel?.text = self.dateFormatter.string(from: date) + cell.detailTextLabel?.text = nil + + let isSelected = (indexPath == self._selectedVersionIndexPath) + configure(cell, isSelected: isSelected, isEnabled: !self.isSyncingRecord) + } + + let remoteVersionsDataSource = RSTArrayTableViewDataSource(items: []) + remoteVersionsDataSource.cellConfigurationHandler = { [weak self] (cell, version, indexPath) in + guard let `self` = self else { return } + + cell.textLabel?.text = self.dateFormatter.string(from: version.version.date) + cell.detailTextLabel?.text = (version.version.identifier == self.record.remoteVersion?.identifier) ? self.record.remoteAuthor : nil + + let isSelected = (self._selectedVersionIndexPath?.section == Section.remote.rawValue && self._selectedVersionIndexPath?.row == indexPath.row) + configure(cell, isSelected: isSelected, isEnabled: !self.isSyncingRecord) + } + + let loadingDataSource = RSTDynamicTableViewDataSource() + loadingDataSource.numberOfSectionsHandler = { 1 } + loadingDataSource.numberOfItemsHandler = { _ in (self.versions == nil) ? 1 : 0 } + loadingDataSource.cellIdentifierHandler = { _ in "LoadingCell" } + loadingDataSource.cellConfigurationHandler = { (_, _, _) in } + + let remoteVersionsLoadingDataSource = RSTCompositeTableViewDataSource(dataSources: [loadingDataSource, remoteVersionsDataSource]) + remoteVersionsLoadingDataSource.shouldFlattenSections = true + + let restoreVersionDataSource = RSTDynamicTableViewDataSource() + restoreVersionDataSource.numberOfSectionsHandler = { 1 } + restoreVersionDataSource.numberOfItemsHandler = { _ in 1} + restoreVersionDataSource.cellIdentifierHandler = { _ in "ConfirmCell" } + restoreVersionDataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in + guard let `self` = self else { return } + + switch self.mode + { + case .restoreVersion: + cell.textLabel?.text = NSLocalizedString("Restore Version", comment: "") + cell.textLabel?.textColor = .deltaPurple + + let isEnabled = (self._selectedVersionIndexPath?.section == Section.remote.rawValue && !self.isSyncingRecord) + configure(cell, isSelected: false, isEnabled: isEnabled) + + case .resolveConflict: + cell.textLabel?.text = NSLocalizedString("Resolve Conflict", comment: "") + cell.textLabel?.textColor = .red + + let isEnabled = (self._selectedVersionIndexPath != nil && !self.isSyncingRecord) + configure(cell, isSelected: false, isEnabled: isEnabled) + } + } + + let dataSource = RSTCompositeTableViewDataSource(dataSources: [localVersionsDataSource, remoteVersionsLoadingDataSource, restoreVersionDataSource]) + dataSource.proxy = self + return dataSource + } + + func fetchVersions() + { + SyncManager.shared.syncCoordinator.fetchVersions(for: self.record) { (result) in + do + { + let versions = try result.value().map(Version.init) + self.versions = versions + + DispatchQueue.main.async { + let count = self.tableView.numberOfRows(inSection: Section.remote.rawValue) + + let deletions = (0 ..< count).map { (row) -> RSTCellContentChange in + let change = RSTCellContentChange(type: .delete, + currentIndexPath: IndexPath(row: row, section: 0), + destinationIndexPath: nil) + change.rowAnimation = .fade + return change + } + + let inserts = (0 ..< versions.count).map { (row) -> RSTCellContentChange in + let change = RSTCellContentChange(type: .insert, + currentIndexPath: nil, + destinationIndexPath: IndexPath(row: row, section: 0)) + change.rowAnimation = .fade + return change + } + + let changes = deletions + inserts + self.remoteVersionsDataSource.setItems(versions, with: changes) + } + } + catch + { + DispatchQueue.main.async { + let alertController = UIAlertController(title: NSLocalizedString("Failed to Fetch Record Versions", comment: ""), message: error.localizedDescription, preferredStyle: .alert) + alertController.addAction(.ok) + self.present(alertController, animated: true, completion: nil) + } + } + } + } + + func restoreVersion() + { + guard !self.isSyncingRecord else { return } + + guard let indexPath = self._selectedVersionIndexPath else { return } + + func finish(_ result: Result>) + { + DispatchQueue.main.async { + + CATransaction.begin() + + CATransaction.setCompletionBlock { + self.isSyncingRecord = false + self._progressObservation = nil + + self.progressView.setHidden(true, animated: true) + + self.tableView.reloadData() + + self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false + + switch result + { + case .success: self.fetchVersions() + case .failure: break + } + } + + do + { + let record = try result.value() + self.record = record + + self.progressView.setProgress(1.0, animated: true) + } + catch + { + let title: String + + switch self.mode + { + case .restoreVersion: title = NSLocalizedString("Failed to Restore Version", comment: "") + case .resolveConflict: title = NSLocalizedString("Failed to Resolve Conflict", comment: "") + } + + let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert) + alertController.addAction(.ok) + self.present(alertController, animated: true, completion: nil) + } + + CATransaction.commit() + } + } + + let progress: Progress + + switch (self.mode, Section.allCases[indexPath.section]) + { + case (.restoreVersion, _): + let version = self.dataSource.item(at: indexPath) + + progress = SyncManager.shared.syncCoordinator.restore(self.record, to: version.version) { (result) in + finish(result) + } + + case (.resolveConflict, .local): + progress = SyncManager.shared.syncCoordinator.resolveConflictedRecord(self.record, resolution: .local) { (result) in + finish(result) + } + + case (.resolveConflict, .remote): + let version = self.dataSource.item(at: indexPath) + + progress = SyncManager.shared.syncCoordinator.resolveConflictedRecord(self.record, resolution: .remote(version.version)) { (result) in + finish(result) + } + + case (.resolveConflict, .confirm): return + } + + self.isSyncingRecord = true + self.navigationItem.rightBarButtonItem?.isIndicatingActivity = true + + self.progressView.progress = 0 + self.progressView.isHidden = false + + self._progressObservation = progress.observe(\.fractionCompleted) { [weak progressView] (_, change) in + DispatchQueue.main.async { + progressView?.setProgress(Float(progress.fractionCompleted), animated: true) + } + } + + self.tableView.reloadData() + } +} + +extension RecordVersionsViewController +{ + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? + { + switch Section.allCases[section] + { + case .local: return NSLocalizedString("Local", comment: "") + case .remote: return NSLocalizedString("Remote", comment: "") + case .confirm: return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + tableView.deselectRow(at: indexPath, animated: true) + + guard let cell = tableView.cellForRow(at: indexPath), cell.selectionStyle != .none else { return } + + switch Section.allCases[indexPath.section] + { + case .local, .remote: + let indexPaths = [indexPath, self._selectedVersionIndexPath, IndexPath(item: 0, section: Section.confirm.rawValue)].compactMap { $0 } + self._selectedVersionIndexPath = indexPath + + tableView.reloadRows(at: indexPaths, with: .none) + + case .confirm: + let message: String + let actionTitle: String + + switch self.mode + { + case .restoreVersion: + message = NSLocalizedString("Restoring a remote version will cause any local changes to be lost.", comment: "") + actionTitle = NSLocalizedString("Restore Version", comment: "") + + case .resolveConflict: + if self._selectedVersionIndexPath?.section == Section.local.rawValue + { + message = NSLocalizedString("The local version will be uploaded and synced to your other devices.", comment: "") + } + else + { + message = NSLocalizedString("Selecting a remote version will cause any local changes to be lost.", comment: "") + } + + actionTitle = NSLocalizedString("Resolve Conflict", comment: "") + } + + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .actionSheet) + alertController.addAction(.cancel) + alertController.addAction(UIAlertAction(title: actionTitle, style: .destructive) { (action) in + self.restoreVersion() + }) + + self.present(alertController, animated: true, completion: nil) + } + } +} diff --git a/Delta/Syncing/SyncManager.swift b/Delta/Syncing/SyncManager.swift index 8df02d4..f39fb86 100644 --- a/Delta/Syncing/SyncManager.swift +++ b/Delta/Syncing/SyncManager.swift @@ -23,7 +23,7 @@ final class SyncManager private(set) var isAuthenticated = false - private let syncCoordinator = SyncCoordinator(service: DriveService.shared, persistentContainer: DatabaseManager.shared) + let syncCoordinator = SyncCoordinator(service: DriveService.shared, persistentContainer: DatabaseManager.shared) private init() { diff --git a/External/Roxas b/External/Roxas index d76e0ee..11afd3f 160000 --- a/External/Roxas +++ b/External/Roxas @@ -1 +1 @@ -Subproject commit d76e0eed8bdcf6ab9ee8d2bbfe522159c29116f5 +Subproject commit 11afd3fd1e3c2d9603d53394452631bd138e2b2c