Adds SyncResultViewController to view errors that occured during sync

This commit is contained in:
Riley Testut 2018-12-04 17:12:36 -08:00
parent eaa8429bd8
commit bace668739
14 changed files with 568 additions and 5 deletions

View File

@ -36,6 +36,10 @@
BF15AF841F54B43B009B6AAB /* ActionInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF15AF831F54B43B009B6AAB /* ActionInput.swift */; };
BF18B61F1E2985F900F70067 /* UIAlertController+Importing.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */; };
BF1DAD5D1D9F576000E752A7 /* SystemControllerSkinsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1DAD5C1D9F576000E752A7 /* SystemControllerSkinsViewController.swift */; };
BF1F45A421AF274D00EF9895 /* SyncResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1F45A321AF274D00EF9895 /* SyncResultViewController.swift */; };
BF1F45AB21AF4B5800EF9895 /* SyncResultsViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BF1F45AA21AF4B5800EF9895 /* SyncResultsViewController.storyboard */; };
BF1F45AD21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1F45AC21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift */; };
BF1F45BF21AF676F00EF9895 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1F45BE21AF676F00EF9895 /* Box.swift */; };
BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BF6BB2451BB73FE800CCF94A /* Assets.xcassets */; };
BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF2B98E51C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift */; };
BF31878B1D489AAA00BD020D /* CheatValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF31878A1D489AAA00BD020D /* CheatValidator.swift */; };
@ -180,6 +184,10 @@
BF15AF831F54B43B009B6AAB /* ActionInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionInput.swift; sourceTree = "<group>"; };
BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Importing.swift"; sourceTree = "<group>"; };
BF1DAD5C1D9F576000E752A7 /* SystemControllerSkinsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemControllerSkinsViewController.swift; sourceTree = "<group>"; };
BF1F45A321AF274D00EF9895 /* SyncResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncResultViewController.swift; sourceTree = "<group>"; };
BF1F45AA21AF4B5800EF9895 /* SyncResultsViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = SyncResultsViewController.storyboard; sourceTree = "<group>"; };
BF1F45AC21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "HarmonyMetadataKey+Keys.swift"; sourceTree = "<group>"; };
BF1F45BE21AF676F00EF9895 /* Box.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = "<group>"; };
BF27CC861BC9E3C600A20D89 /* Delta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Delta.entitlements; sourceTree = "<group>"; };
BF27CC8A1BC9FE4D00A20D89 /* Pods.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Pods.framework; path = "Pods/../build/Debug-appletvos/Pods.framework"; sourceTree = "<group>"; };
BF27CC941BCB7B7A00A20D89 /* GameController.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GameController.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS9.0.sdk/System/Library/Frameworks/GameController.framework; sourceTree = DEVELOPER_DIR; };
@ -327,6 +335,7 @@
BFC6F7B71F435BC500221B96 /* Input+Display.swift */,
BF6424841F5CBDC900D6AB44 /* UIView+ParentViewController.swift */,
BFC3627F21ADE2BA00EF2BE6 /* UIAlertController+Error.swift */,
BF1F45AC21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -404,6 +413,7 @@
isa = PBXGroup;
children = (
BF4828871F90290F00028B97 /* Action.swift */,
BF1F45BE21AF676F00EF9895 /* Box.swift */,
BFE0229C1F5B56840052D888 /* Popover Menu */,
BF5942671E09BBB70051894B /* Collection View */,
BF71CF881FE90471001F1613 /* Table View */,
@ -623,6 +633,8 @@
isa = PBXGroup;
children = (
BFAB9F7C219A43380080EC7D /* SyncManager.swift */,
BF1F45A321AF274D00EF9895 /* SyncResultViewController.swift */,
BF1F45AA21AF4B5800EF9895 /* SyncResultsViewController.storyboard */,
);
path = Syncing;
sourceTree = "<group>";
@ -851,6 +863,7 @@
BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */,
BF71CF8A1FE904B1001F1613 /* GameTableViewCell.xib in Resources */,
BFFC46461D59861000AF2CC6 /* LaunchScreen.storyboard in Resources */,
BF1F45AB21AF4B5800EF9895 /* SyncResultsViewController.storyboard in Resources */,
BF353FF61C5D837600C1184C /* PauseMenu.storyboard in Resources */,
BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */,
);
@ -974,6 +987,7 @@
BF04E6FF1DB8625C000F35D3 /* ControllerSkinsViewController.swift in Sources */,
BF5942891E09BC8B0051894B /* _GameCollection.swift in Sources */,
BF34FA111CF1899D006624C7 /* CheatTextView.swift in Sources */,
BF1F45AD21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift in Sources */,
BF71CF871FE90006001F1613 /* AppIconShortcutsViewController.swift in Sources */,
BF1DAD5D1D9F576000E752A7 /* SystemControllerSkinsViewController.swift in Sources */,
BFE022A01F5B57FF0052D888 /* PopoverMenuButton.swift in Sources */,
@ -983,6 +997,7 @@
BF8DDD241F4F6C880088A21B /* InputCalloutView.swift in Sources */,
BF5942701E09BC5D0051894B /* GamesDatabase.swift in Sources */,
BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */,
BF1F45A421AF274D00EF9895 /* SyncResultViewController.swift in Sources */,
BF5942881E09BC8B0051894B /* _Game.swift in Sources */,
BF95E2771E4977BF0030E7AD /* GameMetadata.swift in Sources */,
BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */,
@ -991,6 +1006,7 @@
BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */,
BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */,
BF5942941E09BD1A0051894B /* NSManagedObject+Conveniences.swift in Sources */,
BF1F45BF21AF676F00EF9895 /* Box.swift in Sources */,
BF7AE80A1C2E8C7600B1B5BC /* UIColor+Delta.swift in Sources */,
BF797A2D1C2D339F00F1A000 /* UILabel+FontSize.swift in Sources */,
BF80E1D21F13117000847008 /* ControllerInputsViewController.swift in Sources */,

View File

@ -0,0 +1,19 @@
//
// Box.swift
// Delta
//
// Created by Riley Testut on 11/28/18.
// Copyright © 2018 Riley Testut. All rights reserved.
//
import Foundation
class Box<T>
{
let value: T
init(_ value: T)
{
self.value = value
}
}

View File

@ -44,4 +44,13 @@ extension Cheat: Syncable
public var syncableRelationships: Set<AnyKeyPath> {
return [\Cheat.game as AnyKeyPath]
}
public var syncableMetadata: [HarmonyMetadataKey : String] {
guard let game = self.game else { return [:] }
return [.gameID: game.identifier, .gameName: game.name]
}
public var syncableLocalizedName: String? {
return self.name
}
}

View File

@ -109,4 +109,8 @@ extension ControllerSkin: Syncable
public var isSyncingEnabled: Bool {
return !self.isStandard
}
public var syncableLocalizedName: String? {
return self.name
}
}

View File

@ -133,4 +133,8 @@ extension Game: Syncable
public var syncableRelationships: Set<AnyKeyPath> {
return [\Game.gameCollection]
}
public var syncableLocalizedName: String? {
return self.name
}
}

View File

@ -39,4 +39,8 @@ extension GameCollection: Syncable
public var syncableKeys: Set<AnyKeyPath> {
return [\GameCollection.index as AnyKeyPath]
}
public var syncableLocalizedName: String? {
return self.name
}
}

View File

@ -96,4 +96,8 @@ extension GameControllerInputMapping: Syncable
\GameControllerInputMapping.gameType,
\GameControllerInputMapping.playerIndex]
}
public var syncableLocalizedName: String? {
return self.name
}
}

View File

@ -125,4 +125,13 @@ extension SaveState: Syncable
public var isSyncingEnabled: Bool {
return self.type != .auto && self.type != .quick
}
public var syncableMetadata: [HarmonyMetadataKey : String] {
guard let game = self.game else { return [:] }
return [.gameID: game.identifier, .gameName: game.name]
}
public var syncableLocalizedName: String? {
return self.localizedName
}
}

View File

@ -0,0 +1,15 @@
//
// HarmonyMetadataKey+Keys.swift
// Harmony
//
// Created by Riley Testut on 11/5/18.
// Copyright © 2018 Riley Testut. All rights reserved.
//
import Harmony
extension HarmonyMetadataKey
{
static let gameID = HarmonyMetadataKey("gameID")
static let gameName = HarmonyMetadataKey("gameName")
}

View File

@ -404,19 +404,30 @@ private extension GamesViewController
@objc func syncingDidFinish(_ notification: Notification)
{
DispatchQueue.main.async {
guard let result = notification.userInfo?[SyncCoordinator.syncResultKey] as? Result<[Result<Void>]> else { return }
guard let result = notification.userInfo?[SyncCoordinator.syncResultKey] as? SyncResult else { return }
let toastView: RSTToastView
switch result
{
case .success: toastView = RSTToastView(text: NSLocalizedString("Sync Complete", comment: ""), detailText: nil)
case .failure(let error): toastView = RSTToastView(error: error)
case .failure(let error as HarmonyError): toastView = RSTToastView(text: NSLocalizedString("Sync Failed", comment: ""), detailText: error.failureReason)
case .failure(let error): toastView = RSTToastView(text: NSLocalizedString("Sync Failed", comment: ""), detailText: error.localizedDescription)
}
toastView.addTarget(self, action: #selector(GamesViewController.presentSyncResultsViewController), for: .touchUpInside)
toastView.show(in: self.view, duration: 2.0)
}
}
@objc func presentSyncResultsViewController()
{
guard let result = SyncManager.shared.previousSyncResult else { return }
let navigationController = SyncResultViewController.make(result: result)
self.present(navigationController, animated: true, completion: nil)
}
}
//MARK: - UIPageViewController -

View File

@ -9,6 +9,39 @@
import Harmony
import Harmony_Drive
extension SyncManager
{
enum RecordType: String, Hashable
{
case game = "Game"
case gameCollection = "GameCollection"
case cheat = "Cheat"
case saveState = "SaveState"
case controllerSkin = "ControllerSkin"
case gameControllerInputMapping = "GameControllerInputMapping"
var localizedName: String {
switch self
{
case .game: return NSLocalizedString("Game", comment: "")
case .gameCollection: return NSLocalizedString("Game Collection", comment: "")
case .cheat: return NSLocalizedString("Cheat", comment: "")
case .saveState: return NSLocalizedString("Save State", comment: "")
case .controllerSkin: return NSLocalizedString("Controller Skin", comment: "")
case .gameControllerInputMapping: return NSLocalizedString("Game Controller Input Mapping", comment: "")
}
}
}
}
extension Syncable where Self: NSManagedObject
{
var recordType: SyncManager.RecordType {
let recordType = SyncManager.RecordType(rawValue: self.syncableType)!
return recordType
}
}
final class SyncManager
{
static let shared = SyncManager()
@ -21,6 +54,8 @@ final class SyncManager
return self.syncCoordinator.recordController
}
private(set) var previousSyncResult: SyncResult?
private(set) var isAuthenticated = false
let syncCoordinator = SyncCoordinator(service: DriveService.shared, persistentContainer: DatabaseManager.shared)
@ -28,6 +63,8 @@ final class SyncManager
private init()
{
DriveService.shared.clientID = "457607414709-5puj6lcv779gpu3ql43e6k3smjj40dmu.apps.googleusercontent.com"
NotificationCenter.default.addObserver(self, selector: #selector(SyncManager.syncingDidFinish(_:)), name: SyncCoordinator.didFinishSyncingNotification, object: nil)
}
}
@ -47,7 +84,7 @@ extension SyncManager
self.isAuthenticated = true
}
catch let error as AuthenticationError where error.code == .noSavedCredentials
catch let error as _AuthenticationError where error.code == .noSavedCredentials
{
// Ignore
}
@ -89,3 +126,12 @@ extension SyncManager
self.syncCoordinator.sync()
}
}
private extension SyncManager
{
@objc func syncingDidFinish(_ notification: Notification)
{
guard let result = notification.userInfo?[SyncCoordinator.syncResultKey] as? SyncResult else { return }
self.previousSyncResult = result
}
}

View File

@ -0,0 +1,329 @@
//
// SyncResultViewController.swift
// Delta
//
// Created by Riley Testut on 11/28/18.
// Copyright © 2018 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
import Harmony
@objc(SyncResultTableViewCell)
private class SyncResultTableViewCell: UITableViewCell
{
@IBOutlet var nameLabel: UILabel!
@IBOutlet var errorLabel: UILabel!
}
extension SyncResultViewController
{
private enum Group: Hashable
{
case game(RecordID)
case saveState(gameID: RecordID)
case cheat(gameID: RecordID)
case controllerSkin
case gameControllerInputMapping
case gameCollection
case other
var sortIndex: Int {
switch self
{
case .game: return 0
case .saveState: return 1
case .cheat: return 2
case .controllerSkin: return 3
case .gameControllerInputMapping: return 4
case .gameCollection: return 5
case .other: return 6
}
}
}
}
class SyncResultViewController: UITableViewController
{
private(set) var result: Result<[Record<NSManagedObject>: Result<Void>]>!
private lazy var dataSource = self.makeDataSource()
private lazy var sortedErrors = self.processResults()
private lazy var gameNamesByRecordID = self.fetchGameNames()
private init()
{
super.init(style: .grouped)
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
}
override func viewDidLoad()
{
super.viewDidLoad()
self.tableView.dataSource = self.dataSource
}
}
extension SyncResultViewController
{
class func make(result: Result<[Record<NSManagedObject>: Result<Void>]>) -> UINavigationController
{
let storyboard = UIStoryboard(name: "SyncResultsViewController", bundle: nil)
let navigationController = storyboard.instantiateInitialViewController() as! UINavigationController
let syncResultViewController = navigationController.viewControllers[0] as! SyncResultViewController
syncResultViewController.result = result
return navigationController
}
}
private extension SyncResultViewController
{
func makeDataSource() -> RSTCompositeTableViewDataSource<Box<Error>>
{
let dataSources = self.sortedErrors.map { (_, errors) -> RSTArrayTableViewDataSource<Box<Error>> in
let dataSource = RSTArrayTableViewDataSource<Box<Error>>(items: errors.map(Box.init))
dataSource.cellConfigurationHandler = { (cell, error, indexPath) in
let cell = cell as! SyncResultTableViewCell
let title: String?
let errorMessage: String?
switch error.value
{
case let error as RecordError:
guard let recordType = SyncManager.RecordType(rawValue: error.record.recordID.type) else { return }
switch recordType
{
case .game: title = NSLocalizedString("Game", comment: "")
case .saveState, .cheat, .controllerSkin, .gameCollection, .gameControllerInputMapping: title = error.record.localizedName ?? recordType.localizedName
}
switch error
{
case .filesFailed(_, let errors):
var messages = [String]()
for error in errors
{
messages.append(error.localizedDescription)
}
errorMessage = messages.joined(separator: "\n")
default: errorMessage = error.failureReason
}
case let error as HarmonyError:
title = error.failureDescription
errorMessage = error.failureReason
case let error:
assertionFailure("Only HarmonyErrors should be thrown by syncing logic.")
title = nil
errorMessage = error.localizedDescription
}
cell.nameLabel.text = title
cell.errorLabel.text = errorMessage
}
return dataSource
}
let placeholderView = RSTPlaceholderView()
placeholderView.textLabel.text = NSLocalizedString("Sync Successful", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("There were no errors during last sync.", comment: "")
let compositeDataSource = RSTCompositeTableViewDataSource(dataSources: dataSources)
compositeDataSource.proxy = self
compositeDataSource.placeholderView = placeholderView
return compositeDataSource
}
private func processResults() -> [(group: Group, errors: [Error])]
{
var errors = [Error]()
do
{
try self.result.verify()
}
catch SyncError.partial(let recordResults)
{
for (_, result) in recordResults
{
guard case .failure(let error) = result else { continue }
errors.append(error)
}
}
catch SyncError.cancelled
{
// Do nothing
}
catch let error as SyncError
{
let error = error.underlyingError ?? error
errors.append(error)
}
catch
{
assertionFailure("Non-SyncError thrown by sync result.")
errors.append(error)
}
var errorsByGroup = [Group: [Error]]()
for error in errors
{
let group: Group
switch error
{
case let error as RecordError:
guard let recordType = SyncManager.RecordType(rawValue: error.record.recordID.type) else { continue }
switch recordType
{
case .game: group = .game(error.record.recordID)
case .gameCollection: group = .gameCollection
case .controllerSkin: group = .controllerSkin
case .gameControllerInputMapping: group = .gameControllerInputMapping
case .saveState:
guard let gameID = error.record.metadata?[.gameID] else { continue }
let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: gameID)
group = .saveState(gameID: recordID)
case .cheat:
guard let gameID = error.record.metadata?[.gameID] else { continue }
let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: gameID)
group = .cheat(gameID: recordID)
}
default: group = .other
}
errorsByGroup[group, default: []].append(error)
}
let sortedErrors = errorsByGroup.sorted { (a, b) in
let groupA = a.key
let groupB = b.key
// Sort initially by game, then sort by type.
// This way games and their associated records (such as save states) are visually grouped together.
switch (groupA, groupB)
{
// Game-related records, but different game identifiers, so sort by game identifiers (implicitly grouping related game records together).
// Using `fallthrough` for these cases seg faults the compiler (as of Swift 4.2.1), so we just duplicate the return expression.
case (.game(let a), .game(let b)) where a != b: return a.identifier < b.identifier
case (.game(let a), .saveState(let b)) where a != b: return a.identifier < b.identifier
case (.game(let a), .cheat(let b)) where a != b: return a.identifier < b.identifier
case (.saveState(let a), .game(let b)) where a != b: return a.identifier < b.identifier
case (.saveState(let a), .saveState(let b)) where a != b: return a.identifier < b.identifier
case (.saveState(let a), .cheat(let b)) where a != b: return a.identifier < b.identifier
case (.cheat(let a), .game(let b)) where a != b: return a.identifier < b.identifier
case (.cheat(let a), .saveState(let b)) where a != b: return a.identifier < b.identifier
case (.cheat(let a), .cheat(let b)) where a != b: return a.identifier < b.identifier
// Otherwise, just return their relative ordering.
case (.game, _): fallthrough
case (.saveState, _): fallthrough
case (.cheat, _): fallthrough
case (.controllerSkin, _): fallthrough
case (.gameControllerInputMapping, _): fallthrough
case (.gameCollection, _): fallthrough
case (.other, _): return groupA.sortIndex < groupB.sortIndex
}
}
return sortedErrors.map { (group: $0.key, errors: $0.value) }
}
func fetchGameNames() -> [RecordID: String]
{
let fetchRequest = Game.fetchRequest() as NSFetchRequest<Game>
fetchRequest.propertiesToFetch = [#keyPath(Game.name), #keyPath(Game.identifier)]
do
{
let games = try DatabaseManager.shared.viewContext.fetch(fetchRequest)
let gameNames = Dictionary(uniqueKeysWithValues: games.map { (RecordID(type: SyncManager.RecordType.game.rawValue, identifier: $0.identifier), $0.name) })
return gameNames
}
catch
{
print("Failed to fetch game names.", error)
return [:]
}
}
}
private extension SyncResultViewController
{
@IBAction func dismiss()
{
self.presentingViewController?.dismiss(animated: true, completion: nil)
}
}
extension SyncResultViewController
{
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
{
let section = self.sortedErrors[section]
switch section.group
{
case .controllerSkin: return NSLocalizedString("Controller Skins", comment: "")
case .gameCollection: return NSLocalizedString("Game Collections", comment: "")
case .gameControllerInputMapping: return NSLocalizedString("Input Mappings", comment: "")
case .other: return NSLocalizedString("Misc.", comment: "")
case .game:
guard let error = section.errors.first as? RecordError else { return nil }
return error.record.localizedName
case .saveState(let gameID):
guard let error = section.errors.first as? RecordError else { return nil }
if let gameName = self.gameNamesByRecordID[gameID] ?? error.record.metadata?[.gameName]
{
return gameName + " - " + NSLocalizedString("Save States", comment: "")
}
else
{
return NSLocalizedString("Save States", comment: "")
}
case .cheat(let gameID):
guard let error = section.errors.first as? RecordError else { return nil }
if let gameName = self.gameNamesByRecordID[gameID] ?? error.record.metadata?[.gameName]
{
return gameName + " - " + NSLocalizedString("Cheats", comment: "")
}
else
{
return NSLocalizedString("Cheats", comment: "")
}
}
}
}

View File

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="PiK-Yl-5r8">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Results-->
<scene sceneID="bU4-NU-gHn">
<objects>
<tableViewController id="Vv7-67-y3h" customClass="SyncResultViewController" customModule="Delta" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="Mge-4Z-RnG">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" rowHeight="81" id="LBH-gN-Qjn" customClass="SyncResultTableViewCell">
<rect key="frame" x="0.0" y="55.5" width="375" height="81"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="LBH-gN-Qjn" id="qeU-Pt-UrC">
<rect key="frame" x="0.0" y="0.0" width="375" height="80.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4.5" translatesAutoresizingMaskIntoConstraints="NO" id="sqc-Zb-cJa">
<rect key="frame" x="16" y="11" width="343" height="59"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title Screen" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="E5a-nn-ak9">
<rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="The record could not be uploaded because an error occured." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="gzf-2D-v9F">
<rect key="frame" x="0.0" y="25" width="343" height="34"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="sqc-Zb-cJa" secondAttribute="trailing" id="uaR-cD-kNU"/>
<constraint firstItem="sqc-Zb-cJa" firstAttribute="leading" secondItem="qeU-Pt-UrC" secondAttribute="leadingMargin" id="xp4-eH-qd4"/>
<constraint firstItem="sqc-Zb-cJa" firstAttribute="top" secondItem="qeU-Pt-UrC" secondAttribute="topMargin" id="z2y-9Y-d1U"/>
<constraint firstItem="sqc-Zb-cJa" firstAttribute="bottom" secondItem="qeU-Pt-UrC" secondAttribute="bottomMargin" id="zT9-af-Xcc"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="errorLabel" destination="gzf-2D-v9F" id="QL8-uA-FvI"/>
<outlet property="nameLabel" destination="E5a-nn-ak9" id="iBv-cv-b6G"/>
</connections>
</tableViewCell>
</prototypes>
<sections/>
<connections>
<outlet property="dataSource" destination="Vv7-67-y3h" id="9Yf-Hl-bIM"/>
<outlet property="delegate" destination="Vv7-67-y3h" id="fDj-g9-Whw"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Results" id="Qzj-eH-8qF">
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="s6x-aF-rco">
<connections>
<action selector="dismiss" destination="Vv7-67-y3h" id="gW2-WJ-4ii"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="x5W-HZ-Oy6" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="365.60000000000002" y="-179.46026986506749"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="d6h-g9-dij">
<objects>
<navigationController id="PiK-Yl-5r8" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EvV-dw-xfY">
<rect key="frame" x="0.0" y="20" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="Vv7-67-y3h" kind="relationship" relationship="rootViewController" id="pdH-jQ-yc1"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ems-7x-mVT" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-457" y="-178"/>
</scene>
</scenes>
</document>

2
External/Harmony vendored

@ -1 +1 @@
Subproject commit c562f4213f8e7f351873435a7add8d755d45f350
Subproject commit 7f28cf57b0f68a26e342d392c94de18b29db9acd