// // 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 } 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 self.update() } } 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? @IBOutlet private var restoreButton: UIBarButtonItem! 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 self.update() } 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 = { [weak self] _ in self?.record.localModificationDate != nil ? 1 : 0 } // fetchVersions() assumes this logic, so update there too. 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 = { [weak self] _ in (self?.versions == nil) ? 1 : 0 } loadingDataSource.cellIdentifierHandler = { _ in "LoadingCell" } loadingDataSource.cellConfigurationHandler = { (_, _, _) in } let remoteVersionsLoadingDataSource = RSTCompositeTableViewDataSource(dataSources: [loadingDataSource, remoteVersionsDataSource]) remoteVersionsLoadingDataSource.shouldFlattenSections = true let dataSource = RSTCompositeTableViewDataSource(dataSources: [localVersionsDataSource, remoteVersionsLoadingDataSource]) dataSource.proxy = self return dataSource } func update() { switch self.mode { case .restoreVersion: self.restoreButton.title = NSLocalizedString("Restore", comment: "") self.restoreButton.tintColor = .deltaPurple self.restoreButton.isEnabled = (self._selectedVersionIndexPath?.section == Section.remote.rawValue) case .resolveConflict: self.restoreButton.title = NSLocalizedString("Resolve", comment: "") self.restoreButton.tintColor = .red self.restoreButton.isEnabled = (self._selectedVersionIndexPath != nil) } } func fetchVersions() { SyncManager.shared.coordinator?.fetchVersions(for: self.record) { (result) in do { let versions = try result.get().map(Version.init) self.versions = versions DispatchQueue.main.async { let previousLocalVersionExists = self.tableView.numberOfRows(inSection: Section.local.rawValue) > 0 let localVersionExists = self.record.localModificationDate != nil let localVersionIndexPath = IndexPath(row: 0, section: Section.local.rawValue) if !previousLocalVersionExists && localVersionExists { self.tableView.insertRows(at: [localVersionIndexPath], with: .fade) } else if previousLocalVersionExists && !localVersionExists { self.tableView.deleteRows(at: [localVersionIndexPath], with: .fade) } 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 } guard let coordinator = SyncManager.shared.coordinator else { return } func finish(_ result: Result) { DispatchQueue.main.async { SyncManager.shared.ignoredCorruptedRecordIDs.remove(self.record.recordID) CATransaction.begin() CATransaction.setCompletionBlock { self.isSyncingRecord = false self._progressObservation = nil self.progressView.setHidden(true, animated: true) self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false self.update() self.tableView.reloadData() switch result { case .success: self.fetchVersions() case .failure: break } } do { let record = try result.get() self.record = record self.progressView.setProgress(1.0, animated: true) } catch { switch error { case RecordError.other(let record, let error as SyncValidationError): // Only allow restoring corrupted records with incorrect games. guard case .incorrectGame = error else { fallthrough } let message = NSLocalizedString("Would you like to download this version anyway? Delta will try to fix the corruption if possible.", comment: "") let alertController = UIAlertController(title: NSLocalizedString("Version Corrupted", comment: ""), message: message, preferredStyle: .alert) alertController.addAction(.cancel) alertController.addAction(UIAlertAction(title: NSLocalizedString("Download Anyway", comment: ""), style: .destructive) { _ in SyncManager.shared.ignoredCorruptedRecordIDs.insert(record.recordID) self.restoreVersion() }) self.present(alertController, animated: true, completion: nil) default: 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 = coordinator.restore(self.record, to: version.version) { (result) in finish(result) } case (.resolveConflict, .local): progress = coordinator.resolveConflictedRecord(self.record, resolution: .local) { (result) in finish(result) } case (.resolveConflict, .remote): let version = self.dataSource.item(at: indexPath) progress = coordinator.resolveConflictedRecord(self.record, resolution: .remote(version.version)) { (result) in finish(result) } } 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() } } private extension RecordVersionsViewController { @IBAction func restore(_ sender: UIBarButtonItem) { 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.popoverPresentationController?.barButtonItem = sender alertController.addAction(.cancel) alertController.addAction(UIAlertAction(title: actionTitle, style: .destructive) { (action) in self.restoreVersion() }) self.present(alertController, animated: true, completion: nil) } } extension RecordVersionsViewController { override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch Section.allCases[section] { case .local: return NSLocalizedString("On Device", comment: "") case .remote: return NSLocalizedString("Cloud", comment: "") } } 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 } let indexPaths = [indexPath, self._selectedVersionIndexPath].compactMap { $0 } self._selectedVersionIndexPath = indexPath tableView.reloadRows(at: indexPaths, with: .none) self.update() } }