diff --git a/Cores/DeltaCore b/Cores/DeltaCore index 6658b7a..4947d10 160000 --- a/Cores/DeltaCore +++ b/Cores/DeltaCore @@ -1 +1 @@ -Subproject commit 6658b7a7557278c9b846466a0599462c1aeb870b +Subproject commit 4947d10713ba31017e40df275f40a315cf0da897 diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 13f9aef..a541504 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ BF4828841F9027B600028B97 /* Delta.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = BF4828811F9027B600028B97 /* Delta.xcdatamodeld */; }; BF4828861F9028F500028B97 /* System.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF4828851F9028F500028B97 /* System.swift */; }; BF4828881F90290F00028B97 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF4828871F90290F00028B97 /* Action.swift */; }; + BF525EE81FF5F370004AA849 /* DeepLinkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF525EE71FF5F370004AA849 /* DeepLinkController.swift */; }; + BF525EEA1FF6CD12004AA849 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF525EE91FF6CD12004AA849 /* DeepLink.swift */; }; BF5942641E09BBB10051894B /* LoadControllerSkinImageOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5942611E09BBB10051894B /* LoadControllerSkinImageOperation.swift */; }; BF5942661E09BBB10051894B /* LoadImageURLOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5942631E09BBB10051894B /* LoadImageURLOperation.swift */; }; BF59426A1E09BBD00051894B /* GridCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5942681E09BBD00051894B /* GridCollectionViewCell.swift */; }; @@ -85,6 +87,8 @@ BF6EE5EB1F7C5F8F0051AD6C /* GameControllerInputMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6EE5EA1F7C5F8F0051AD6C /* GameControllerInputMapping.swift */; }; BF70798C1B6B464B0019077C /* ZipZap.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF70798B1B6B464B0019077C /* ZipZap.framework */; }; BF70798D1B6B464B0019077C /* ZipZap.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF70798B1B6B464B0019077C /* ZipZap.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BF71CF871FE90006001F1613 /* AppIconShortcutsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF71CF861FE90006001F1613 /* AppIconShortcutsViewController.swift */; }; + BF71CF8A1FE904B1001F1613 /* GameTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BF71CF891FE904B1001F1613 /* GameTableViewCell.xib */; }; BF797A2D1C2D339F00F1A000 /* UILabel+FontSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF797A2C1C2D339F00F1A000 /* UILabel+FontSize.swift */; }; BF7AE8081C2E858400B1B5BC /* GridMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7AE8041C2E858400B1B5BC /* GridMenuViewController.swift */; }; BF7AE80A1C2E8C7600B1B5BC /* UIColor+Delta.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7AE8091C2E8C7600B1B5BC /* UIColor+Delta.swift */; }; @@ -115,7 +119,7 @@ BFF0742D1E9DC17500ACDF4A /* GBCDeltaCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BFF0742B1E9DC17500ACDF4A /* GBCDeltaCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BFF6452E1F7CC5060056533E /* GameControllerInputMappingTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6B82A41F7CC2A300042BFB /* GameControllerInputMappingTransformer.swift */; }; BFF93AA01E0FB036005EC865 /* InputStreamOutputWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF93A9F1E0FB036005EC865 /* InputStreamOutputWriter.swift */; }; - BFFA4C091E8A24D600D87934 /* GameMetadataTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFA4C081E8A24D600D87934 /* GameMetadataTableViewCell.swift */; }; + BFFA4C091E8A24D600D87934 /* GameTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFA4C081E8A24D600D87934 /* GameTableViewCell.swift */; }; BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFA71DC1AAC406100EE9DD1 /* AppDelegate.swift */; }; BFFA71E21AAC406100EE9DD1 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFFA71E01AAC406100EE9DD1 /* Main.storyboard */; }; BFFC461E1D59823500AF2CC6 /* GamesPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFC461B1D59823500AF2CC6 /* GamesPresentationController.swift */; }; @@ -180,6 +184,8 @@ BF4828831F9027B600028B97 /* Delta.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Delta.xcdatamodel; sourceTree = ""; }; BF4828851F9028F500028B97 /* System.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = System.swift; sourceTree = ""; }; BF4828871F90290F00028B97 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + BF525EE71FF5F370004AA849 /* DeepLinkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkController.swift; sourceTree = ""; }; + BF525EE91FF6CD12004AA849 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; BF5942611E09BBB10051894B /* LoadControllerSkinImageOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadControllerSkinImageOperation.swift; sourceTree = ""; }; BF5942631E09BBB10051894B /* LoadImageURLOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadImageURLOperation.swift; sourceTree = ""; }; BF5942681E09BBD00051894B /* GridCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridCollectionViewCell.swift; sourceTree = ""; }; @@ -221,6 +227,8 @@ BF6EE5E81F7C5F860051AD6C /* _GameControllerInputMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _GameControllerInputMapping.swift; sourceTree = ""; }; BF6EE5EA1F7C5F8F0051AD6C /* GameControllerInputMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerInputMapping.swift; sourceTree = ""; }; BF70798B1B6B464B0019077C /* ZipZap.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = ZipZap.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BF71CF861FE90006001F1613 /* AppIconShortcutsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconShortcutsViewController.swift; sourceTree = ""; }; + BF71CF891FE904B1001F1613 /* GameTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = GameTableViewCell.xib; sourceTree = ""; }; BF797A2C1C2D339F00F1A000 /* UILabel+FontSize.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UILabel+FontSize.swift"; sourceTree = ""; }; BF7AE8041C2E858400B1B5BC /* GridMenuViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridMenuViewController.swift; sourceTree = ""; }; BF7AE8091C2E8C7600B1B5BC /* UIColor+Delta.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Delta.swift"; sourceTree = ""; }; @@ -243,7 +251,7 @@ BFEF24F21F7DD4FB00454C62 /* SaveStateMigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveStateMigrationPolicy.swift; sourceTree = ""; }; BFF0742B1E9DC17500ACDF4A /* GBCDeltaCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = GBCDeltaCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BFF93A9F1E0FB036005EC865 /* InputStreamOutputWriter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputStreamOutputWriter.swift; sourceTree = ""; }; - BFFA4C081E8A24D600D87934 /* GameMetadataTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameMetadataTableViewCell.swift; sourceTree = ""; }; + BFFA4C081E8A24D600D87934 /* GameTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameTableViewCell.swift; sourceTree = ""; }; BFFA71D71AAC406100EE9DD1 /* Delta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Delta.app; sourceTree = BUILT_PRODUCTS_DIR; }; BFFA71DB1AAC406100EE9DD1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BFFA71DC1AAC406100EE9DD1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -343,12 +351,22 @@ path = "Game Selection"; sourceTree = ""; }; + BF525EE61FF5F355004AA849 /* Deep Linking */ = { + isa = PBXGroup; + children = ( + BF525EE91FF6CD12004AA849 /* DeepLink.swift */, + BF525EE71FF5F370004AA849 /* DeepLinkController.swift */, + ); + path = "Deep Linking"; + sourceTree = ""; + }; BF5942571E09BB5D0051894B /* Components */ = { isa = PBXGroup; children = ( BF4828871F90290F00028B97 /* Action.swift */, BFE0229C1F5B56840052D888 /* Popover Menu */, BF5942671E09BBB70051894B /* Collection View */, + BF71CF881FE90471001F1613 /* Table View */, BF5942601E09BBA80051894B /* Loading */, ); path = Components; @@ -467,6 +485,23 @@ path = "Import Options"; sourceTree = ""; }; + BF71CF851FE8FFF1001F1613 /* App Icon Shortcuts */ = { + isa = PBXGroup; + children = ( + BF71CF861FE90006001F1613 /* AppIconShortcutsViewController.swift */, + ); + path = "App Icon Shortcuts"; + sourceTree = ""; + }; + BF71CF881FE90471001F1613 /* Table View */ = { + isa = PBXGroup; + children = ( + BFFA4C081E8A24D600D87934 /* GameTableViewCell.swift */, + BF71CF891FE904B1001F1613 /* GameTableViewCell.xib */, + ); + path = "Table View"; + sourceTree = ""; + }; BF7AE7FA1C2E851F00B1B5BC /* Pause Menu */ = { isa = PBXGroup; children = ( @@ -506,7 +541,6 @@ BF59426E1E09BC5D0051894B /* GamesDatabase.swift */, BF95E2761E4977BF0030E7AD /* GameMetadata.swift */, BF95E2781E4982A10030E7AD /* GamesDatabaseBrowserViewController.swift */, - BFFA4C081E8A24D600D87934 /* GameMetadataTableViewCell.swift */, ); path = OpenVGDB; sourceTree = ""; @@ -534,6 +568,7 @@ BFAA1FEC1B8AA4FA00495943 /* Settings.swift */, BF5E7F451B9A652600AE44F8 /* Settings.storyboard */, BF5E7F431B9A650B00AE44F8 /* SettingsViewController.swift */, + BF71CF851FE8FFF1001F1613 /* App Icon Shortcuts */, BF11734E1DA32CEC00047DF8 /* Controllers */, BF1DAD5B1D9F574900E752A7 /* Controller Skins */, ); @@ -620,6 +655,7 @@ BF59426C1E09BC450051894B /* Database */, BF59428C1E09BCE50051894B /* Importing */, BF930FFB1EB6D6EC00E8DBA0 /* Systems */, + BF525EE61FF5F355004AA849 /* Deep Linking */, BF5942571E09BB5D0051894B /* Components */, BF696B7E1D9B2AE6009639E0 /* Theming */, BF090CEE1B490C1A00DCAB45 /* Extensions */, @@ -759,6 +795,7 @@ BF3540001C5DA3C500C1184C /* PausePresentationControllerContentView.xib in Resources */, BF5E7F461B9A652600AE44F8 /* Settings.storyboard in Resources */, BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */, + BF71CF8A1FE904B1001F1613 /* GameTableViewCell.xib in Resources */, BFFC46461D59861000AF2CC6 /* LaunchScreen.storyboard in Resources */, BF353FF61C5D837600C1184C /* PauseMenu.storyboard in Resources */, BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */, @@ -855,6 +892,7 @@ BF59427C1E09BC830051894B /* Cheat.swift in Sources */, BFBAB2E31EB685A2004E0B0E /* DeltaCoreProtocol+Delta.swift in Sources */, BF3540081C5DAFAD00C1184C /* PauseTransitionCoordinator.swift in Sources */, + BF525EEA1FF6CD12004AA849 /* DeepLink.swift in Sources */, BF59427E1E09BC830051894B /* Game.swift in Sources */, BFAA1FED1B8AA4FA00495943 /* Settings.swift in Sources */, BFA0D1271D3AE1F600565894 /* GameViewController.swift in Sources */, @@ -875,15 +913,17 @@ BFDD04F11D5E2C27002D450E /* GameCollectionViewController.swift in Sources */, BFE4269E1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift in Sources */, BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */, + BF525EE81FF5F370004AA849 /* DeepLinkController.swift in Sources */, BF59426B1E09BBD00051894B /* GridCollectionViewLayout.swift in Sources */, BF6424851F5CBDC900D6AB44 /* UIView+ParentViewController.swift in Sources */, BF04E6FF1DB8625C000F35D3 /* ControllerSkinsViewController.swift in Sources */, BF5942891E09BC8B0051894B /* _GameCollection.swift in Sources */, BF34FA111CF1899D006624C7 /* CheatTextView.swift in Sources */, + BF71CF871FE90006001F1613 /* AppIconShortcutsViewController.swift in Sources */, BF1DAD5D1D9F576000E752A7 /* SystemControllerSkinsViewController.swift in Sources */, BFE022A01F5B57FF0052D888 /* PopoverMenuButton.swift in Sources */, BF6BF31C1EB821A0008E83CD /* GamesDatabaseImportOption.swift in Sources */, - BFFA4C091E8A24D600D87934 /* GameMetadataTableViewCell.swift in Sources */, + BFFA4C091E8A24D600D87934 /* GameTableViewCell.swift in Sources */, BFFC46231D5984A000AF2CC6 /* LaunchViewController.swift in Sources */, BF8DDD241F4F6C880088A21B /* InputCalloutView.swift in Sources */, BF5942701E09BC5D0051894B /* GamesDatabase.swift in Sources */, diff --git a/Delta/AppDelegate.swift b/Delta/AppDelegate.swift index ac4ce6a..73d209d 100644 --- a/Delta/AppDelegate.swift +++ b/Delta/AppDelegate.swift @@ -17,6 +17,8 @@ import Crashlytics class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + + private let deepLinkController = DeepLinkController() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { @@ -35,12 +37,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate } // Database - DatabaseManager.shared.loadPersistentStores { (description, error) in } // Controllers ExternalGameControllerManager.shared.startMonitoring() + + // Deep Links + if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem + { + self.deepLinkController.handle(.shortcut(shortcut)) + + // false = we handled the deep link, so no need to call delegate method separately. + return false + } return true } @@ -78,8 +88,6 @@ extension AppDelegate func configureAppearance() { self.window?.tintColor = UIColor.deltaPurple - - UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).defaultTextAttributes[NSAttributedStringKey.foregroundColor.rawValue] = UIColor.white } } @@ -92,15 +100,20 @@ extension AppDelegate @discardableResult private func openURL(_ url: URL) -> Bool { - guard url.isFileURL else { return false } - - if GameType(fileExtension: url.pathExtension) != nil || url.pathExtension.lowercased() == "zip" + if url.isFileURL { - return self.importGame(at: url) + if GameType(fileExtension: url.pathExtension) != nil || url.pathExtension.lowercased() == "zip" + { + return self.importGame(at: url) + } + else if url.pathExtension.lowercased() == "deltaskin" + { + return self.importControllerSkin(at: url) + } } - else if url.pathExtension.lowercased() == "deltaskin" + else { - return self.importControllerSkin(at: url) + return self.deepLinkController.handle(.url(url)) } return false @@ -145,3 +158,12 @@ extension AppDelegate } } +extension AppDelegate +{ + func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) + { + let result = self.deepLinkController.handle(.shortcut(shortcutItem)) + completionHandler(result) + } +} + diff --git a/Delta/Base.lproj/GamesDatabase.storyboard b/Delta/Base.lproj/GamesDatabase.storyboard index 28aeae1..4ef0bec 100644 --- a/Delta/Base.lproj/GamesDatabase.storyboard +++ b/Delta/Base.lproj/GamesDatabase.storyboard @@ -1,12 +1,11 @@ - + - - + @@ -18,52 +17,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -101,7 +54,4 @@ - - - diff --git a/Delta/Base.lproj/PauseMenu.storyboard b/Delta/Base.lproj/PauseMenu.storyboard index 9262ba1..3a7a744 100644 --- a/Delta/Base.lproj/PauseMenu.storyboard +++ b/Delta/Base.lproj/PauseMenu.storyboard @@ -100,7 +100,7 @@ - + diff --git a/Delta/Base.lproj/Settings.storyboard b/Delta/Base.lproj/Settings.storyboard index 2b79817..ed24fe3 100644 --- a/Delta/Base.lproj/Settings.storyboard +++ b/Delta/Base.lproj/Settings.storyboard @@ -19,7 +19,7 @@ + + + + + + + + + + + + + + + + + + @@ -441,6 +465,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Delta/Database/OpenVGDB/GameMetadataTableViewCell.swift b/Delta/Components/Table View/GameTableViewCell.swift similarity index 89% rename from Delta/Database/OpenVGDB/GameMetadataTableViewCell.swift rename to Delta/Components/Table View/GameTableViewCell.swift index 3445ea5..0928d6b 100644 --- a/Delta/Database/OpenVGDB/GameMetadataTableViewCell.swift +++ b/Delta/Components/Table View/GameTableViewCell.swift @@ -1,5 +1,5 @@ // -// GameMetadataTableViewCell.swift +// GameTableViewCell.swift // Delta // // Created by Riley Testut on 3/27/17. @@ -8,7 +8,7 @@ import UIKit -class GameMetadataTableViewCell: UITableViewCell +class GameTableViewCell: UITableViewCell { @IBOutlet private(set) var nameLabel: UILabel! @IBOutlet private(set) var artworkImageView: UIImageView! diff --git a/Delta/Components/Table View/GameTableViewCell.xib b/Delta/Components/Table View/GameTableViewCell.xib new file mode 100644 index 0000000..79de568 --- /dev/null +++ b/Delta/Components/Table View/GameTableViewCell.xib @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Delta/Database/DatabaseManager.swift b/Delta/Database/DatabaseManager.swift index d5aa731..0d7fbd9 100644 --- a/Delta/Database/DatabaseManager.swift +++ b/Delta/Database/DatabaseManager.swift @@ -62,6 +62,8 @@ final class DatabaseManager: NSPersistentContainer private var gamesDatabase: GamesDatabase? = nil + private var validationManagedObjectContext: NSManagedObjectContext? + private init() { guard @@ -94,11 +96,39 @@ extension DatabaseManager } } +//MARK: - Update - +private extension DatabaseManager +{ + func updateRecentGameShortcuts() + { + guard let managedObjectContext = self.validationManagedObjectContext else { return } + + guard Settings.gameShortcutsMode == .recent else { return } + + let fetchRequest = Game.recentlyPlayedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + + do + { + let games = try managedObjectContext.fetch(fetchRequest) + Settings.gameShortcuts = games + } + catch + { + print(error) + } + } +} + //MARK: - Preparation - private extension DatabaseManager { func prepareDatabase(completion: @escaping () -> Void) { + self.validationManagedObjectContext = self.newBackgroundContext() + + NotificationCenter.default.addObserver(self, selector: #selector(DatabaseManager.validateManagedObjectContextSave(with:)), name: .NSManagedObjectContextDidSave, object: nil) + self.performBackgroundTask { (context) in for system in System.supportedSystems @@ -137,7 +167,6 @@ private extension DatabaseManager } completion() - } } } @@ -502,6 +531,28 @@ extension DatabaseManager } } +//MARK: - Notifications - +private extension DatabaseManager +{ + @objc func validateManagedObjectContextSave(with notification: Notification) + { + guard (notification.object as? NSManagedObjectContext) != self.validationManagedObjectContext else { return } + + let insertedObjects = (notification.userInfo?[NSInsertedObjectsKey] as? Set) ?? [] + let updatedObjects = (notification.userInfo?[NSUpdatedObjectsKey] as? Set) ?? [] + let deletedObjects = (notification.userInfo?[NSDeletedObjectsKey] as? Set) ?? [] + + let allObjects = insertedObjects.union(updatedObjects).union(deletedObjects) + + if allObjects.contains(where: { $0 is Game }) + { + self.validationManagedObjectContext?.perform { + self.updateRecentGameShortcuts() + } + } + } +} + //MARK: - Private - private extension DatabaseManager { diff --git a/Delta/Database/Model/Delta.xcdatamodeld/Delta 2.xcdatamodel/contents b/Delta/Database/Model/Delta.xcdatamodeld/Delta 2.xcdatamodel/contents index 87478a0..6febc5b 100644 --- a/Delta/Database/Model/Delta.xcdatamodeld/Delta 2.xcdatamodel/contents +++ b/Delta/Database/Model/Delta.xcdatamodeld/Delta 2.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -54,6 +54,7 @@ + @@ -130,7 +131,7 @@ - + diff --git a/Delta/Database/Model/Human/Game.swift b/Delta/Database/Model/Human/Game.swift index ad8879b..9fa807a 100644 --- a/Delta/Database/Model/Human/Game.swift +++ b/Delta/Database/Model/Human/Game.swift @@ -55,6 +55,18 @@ public class Game: _Game, GameProtocol } } +extension Game +{ + class var recentlyPlayedFetchRequest: NSFetchRequest { + let fetchRequest: NSFetchRequest = Game.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K != nil", #keyPath(Game.playedDate)) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Game.playedDate, ascending: false), NSSortDescriptor(keyPath: \Game.name, ascending: true)] + fetchRequest.fetchLimit = 4 + + return fetchRequest + } +} + extension Game { override public func prepareForDeletion() diff --git a/Delta/Database/Model/Machine/_Game.swift b/Delta/Database/Model/Machine/_Game.swift index 88d139b..de5809e 100644 --- a/Delta/Database/Model/Machine/_Game.swift +++ b/Delta/Database/Model/Machine/_Game.swift @@ -22,6 +22,8 @@ public class _Game: NSManagedObject @NSManaged public var name: String + @NSManaged public var playedDate: Date? + @NSManaged public var type: GameType // MARK: - Relationships diff --git a/Delta/Database/OpenVGDB/GamesDatabaseBrowserViewController.swift b/Delta/Database/OpenVGDB/GamesDatabaseBrowserViewController.swift index 132c586..ef98057 100644 --- a/Delta/Database/OpenVGDB/GamesDatabaseBrowserViewController.swift +++ b/Delta/Database/OpenVGDB/GamesDatabaseBrowserViewController.swift @@ -54,6 +54,8 @@ class GamesDatabaseBrowserViewController: UITableViewController self.view.backgroundColor = UIColor.deltaDarkGray + self.tableView.register(GameTableViewCell.nib!, forCellReuseIdentifier: RSTCellContentGenericCellIdentifier) + self.tableView.dataSource = self.dataSource self.tableView.prefetchDataSource = self.dataSource @@ -61,7 +63,7 @@ class GamesDatabaseBrowserViewController: UITableViewController self.tableView.separatorColor = UIColor.gray self.dataSource.searchController.delegate = self - self.dataSource.searchController.searchBar.barStyle = .blackTranslucent + self.dataSource.searchController.searchBar.barStyle = .black if #available(iOS 11, *) { @@ -95,7 +97,7 @@ private extension GamesDatabaseBrowserViewController /* Cell Configuration */ self.dataSource.cellConfigurationHandler = { [unowned self] (cell, metadata, indexPath) in - self.configure(cell: cell as! GameMetadataTableViewCell, with: metadata, for: indexPath) + self.configure(cell: cell as! GameTableViewCell, with: metadata, for: indexPath) } @@ -113,7 +115,7 @@ private extension GamesDatabaseBrowserViewController self.dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in guard let image = image else { return } - let cell = cell as! GameMetadataTableViewCell + let cell = cell as! GameTableViewCell let artworkDisplaySize = AVMakeRect(aspectRatio: image.size, insideRect: cell.artworkImageView.bounds) let offset = (cell.artworkImageView.bounds.width - artworkDisplaySize.width) / 2 @@ -150,7 +152,7 @@ private extension GamesDatabaseBrowserViewController private extension GamesDatabaseBrowserViewController { - func configure(cell: GameMetadataTableViewCell, with metadata: GameMetadata, for indexPath: IndexPath) + func configure(cell: GameTableViewCell, with metadata: GameMetadata, for indexPath: IndexPath) { cell.backgroundColor = UIColor.deltaDarkGray diff --git a/Delta/Deep Linking/DeepLink.swift b/Delta/Deep Linking/DeepLink.swift new file mode 100644 index 0000000..0f02379 --- /dev/null +++ b/Delta/Deep Linking/DeepLink.swift @@ -0,0 +1,103 @@ +// +// DeepLink.swift +// Delta +// +// Created by Riley Testut on 12/29/17. +// Copyright © 2017 Riley Testut. All rights reserved. +// + +import UIKit + +extension URL +{ + init(action: DeepLink.Action) + { + var components = URLComponents() + components.host = action.type.rawValue + + switch action + { + case .launchGame(let identifier): components.path = identifier + } + + let url = components.url! + self = url + } +} + +extension UIApplicationShortcutItem +{ + convenience init(localizedTitle: String, action: DeepLink.Action) + { + var userInfo: [AnyHashable: Any]? + + switch action + { + case .launchGame(let identifier): userInfo = [DeepLink.Key.identifier.rawValue: identifier] + } + + self.init(type: action.type.rawValue, localizedTitle: localizedTitle, localizedSubtitle: nil, icon: nil, userInfo: userInfo) + } +} + +extension DeepLink +{ + enum Action + { + case launchGame(identifier: String) + + var type: ActionType { + switch self + { + case .launchGame: return .launchGame + } + } + } + + enum ActionType: String + { + case launchGame = "game" + } + + enum Key: String + { + case identifier + case game + } +} + +enum DeepLink +{ + case url(URL) + case shortcut(UIApplicationShortcutItem) + + var actionType: ActionType? { + switch self + { + case .url(let url): + guard let host = url.host else { return nil } + + let type = ActionType(rawValue: host) + return type + + case .shortcut(let shortcut): + let type = ActionType(rawValue: shortcut.type) + return type + } + } + + var action: Action? { + guard let type = self.actionType else { return nil } + + switch (self, type) + { + case (.url(let url), .launchGame): + let identifier = url.lastPathComponent + return .launchGame(identifier: identifier) + + case (.shortcut(let shortcut), .launchGame): + guard let identifier = shortcut.userInfo?[Key.identifier.rawValue] as? String else { return nil } + return .launchGame(identifier: identifier) + } + } +} diff --git a/Delta/Deep Linking/DeepLinkController.swift b/Delta/Deep Linking/DeepLinkController.swift new file mode 100644 index 0000000..a64d9db --- /dev/null +++ b/Delta/Deep Linking/DeepLinkController.swift @@ -0,0 +1,83 @@ +// +// DeepLinkController.swift +// Delta +// +// Created by Riley Testut on 12/28/17. +// Copyright © 2017 Riley Testut. All rights reserved. +// + +import UIKit + +extension Notification.Name +{ + static let deepLinkControllerLaunchGame = Notification.Name("deepLinkControllerLaunchGame") +} + +extension UIViewController +{ + var allowsDeepLinkingDismissal: Bool { + return true + } +} + +struct DeepLinkController +{ + private var window: UIWindow? { + guard let delegate = UIApplication.shared.delegate, let window = delegate.window else { return nil } + return window + } + + private var topViewController: UIViewController? { + guard let window = self.window else { return nil } + + var topViewController = window.rootViewController + while topViewController?.presentedViewController != nil + { + guard !(topViewController?.presentedViewController is UIAlertController) else { break } + + topViewController = topViewController?.presentedViewController + } + + return topViewController + } +} + +extension DeepLinkController +{ + @discardableResult func handle(_ deepLink: DeepLink) -> Bool + { + guard let action = deepLink.action else { return false } + + switch action + { + case .launchGame(let identifier): return self.launchGame(withIdentifier: identifier) + } + } +} + +private extension DeepLinkController +{ + func launchGame(withIdentifier identifier: String) -> Bool + { + guard let topViewController = self.topViewController, topViewController.allowsDeepLinkingDismissal else { return false } + + let fetchRequest: NSFetchRequest = Game.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), identifier) + fetchRequest.returnsObjectsAsFaults = false + + do + { + guard let game = try DatabaseManager.shared.viewContext.fetch(fetchRequest).first else { return false } + + NotificationCenter.default.post(name: .deepLinkControllerLaunchGame, object: self, userInfo: [DeepLink.Key.game: game]) + } + catch + { + print(error) + + return false + } + + return true + } +} diff --git a/Delta/Emulation/GameViewController.swift b/Delta/Emulation/GameViewController.swift index 6a12672..908eed6 100644 --- a/Delta/Emulation/GameViewController.swift +++ b/Delta/Emulation/GameViewController.swift @@ -93,6 +93,21 @@ class GameViewController: DeltaCore.GameViewController } } + private var _deepLinkResumingSaveState: SaveStateProtocol? { + didSet { + guard let saveState = oldValue, _deepLinkResumingSaveState == nil else { return } + + do + { + try FileManager.default.removeItem(at: saveState.fileURL) + } + catch + { + print(error) + } + } + } + private var _isLoadingSaveState = false private var context = CIContext(options: [kCIContextWorkingColorSpace: NSNull()]) @@ -127,6 +142,7 @@ class GameViewController: DeltaCore.GameViewController NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.updateControllers), name: .externalGameControllerDidDisconnect, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.didEnterBackground(with:)), name: .UIApplicationDidEnterBackground, object: UIApplication.shared) NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.settingsDidChange(with:)), name: .settingsDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.deepLinkControllerLaunchGame(with:)), name: .deepLinkControllerLaunchGame, object: nil) } deinit @@ -364,7 +380,11 @@ extension GameViewController @IBAction private func unwindFromGamesViewController(with segue: UIStoryboardSegue) { self.pausedSaveState = nil - self.emulatorCore?.resume() + + if let emulatorCore = self.emulatorCore, emulatorCore.state == .paused + { + emulatorCore.resume() + } } // MARK: - KVO @@ -375,10 +395,39 @@ extension GameViewController guard let rawValue = change?[.oldKey] as? Int, let previousState = EmulatorCore.State(rawValue: rawValue) else { return } + if let saveState = _deepLinkResumingSaveState, let emulatorCore = self.emulatorCore, emulatorCore.state == .running + { + emulatorCore.pause() + + do + { + try emulatorCore.load(saveState) + } + catch + { + print(error) + } + + _deepLinkResumingSaveState = nil + emulatorCore.resume() + } + if previousState == .stopped { self.emulatorCore?.updateCheats() } + + if self.emulatorCore?.state == .running + { + DatabaseManager.shared.performBackgroundTask { (context) in + guard let game = self.game as? Game else { return } + + let backgroundGame = context.object(with: game.objectID) as! Game + backgroundGame.playedDate = Date() + + context.saveWithErrorLogging() + } + } } } @@ -812,7 +861,8 @@ extension GameViewController: GameViewControllerDelegate func gameViewControllerShouldResumeEmulation(_ gameViewController: DeltaCore.GameViewController) -> Bool { - return (self.presentedViewController == nil || self.presentedViewController?.isDisappearing == true) && !self.isSelectingSustainedButtons && self.view.window != nil + let result = (self.presentedViewController == nil || self.presentedViewController?.isDisappearing == true) && !self.isSelectingSustainedButtons && self.view.window != nil + return result } } @@ -858,4 +908,44 @@ private extension GameViewController case .translucentControllerSkinOpacity: self.controllerView.translucentControllerSkinOpacity = Settings.translucentControllerSkinOpacity } } + + @objc func deepLinkControllerLaunchGame(with notification: Notification) + { + guard let game = notification.userInfo?[DeepLink.Key.game] as? Game else { return } + + self.game = game + + if let pausedSaveState = self.pausedSaveState, game == (self.game as? Game) + { + // Launching current game via deep link, so we store a copy of the paused save state to resume when emulator core is started. + + do + { + let temporaryURL = FileManager.default.uniqueTemporaryURL() + try FileManager.default.copyItem(at: pausedSaveState.fileURL, to: temporaryURL) + + _deepLinkResumingSaveState = DeltaCore.SaveState(fileURL: temporaryURL, gameType: game.type) + } + catch + { + print(error) + } + } + + if let pauseViewController = self.pauseViewController + { + let segue = UIStoryboardSegue(identifier: "unwindFromPauseMenu", source: pauseViewController, destination: self) + self.unwindFromPauseViewController(segue) + } + else if + let navigationController = self.presentedViewController as? UINavigationController, + let pageViewController = navigationController.topViewController?.childViewControllers.first as? UIPageViewController, + let gameCollectionViewController = pageViewController.viewControllers?.first as? GameCollectionViewController + { + let segue = UIStoryboardSegue(identifier: "unwindFromGames", source: gameCollectionViewController, destination: self) + self.unwindFromGamesViewController(with: segue) + } + + self.dismiss(animated: true, completion: nil) + } } diff --git a/Delta/Game Selection/GamesViewController.swift b/Delta/Game Selection/GamesViewController.swift index 4a969cb..d4164d1 100644 --- a/Delta/Game Selection/GamesViewController.swift +++ b/Delta/Game Selection/GamesViewController.swift @@ -200,6 +200,8 @@ private extension GamesViewController searchResultsController?.dataSource.predicate = searchValue.predicate return nil } + self.searchController?.searchBar.barStyle = .black + self.navigationItem.searchController = self.searchController self.navigationItem.hidesSearchBarWhenScrolling = false diff --git a/Delta/Launch/LaunchViewController.swift b/Delta/Launch/LaunchViewController.swift index 3b38c96..4ea2511 100644 --- a/Delta/Launch/LaunchViewController.swift +++ b/Delta/Launch/LaunchViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import Roxas class LaunchViewController: UIViewController { @@ -15,6 +16,8 @@ class LaunchViewController: UIViewController private var presentedGameViewController: Bool = false + private var applicationLaunchDeepLinkGame: Game? + override var preferredStatusBarStyle: UIStatusBarStyle { return self.gameViewController?.preferredStatusBarStyle ?? .lightContent } @@ -27,6 +30,13 @@ class LaunchViewController: UIViewController return self.gameViewController } + required init?(coder aDecoder: NSCoder) + { + super.init(coder: aDecoder) + + NotificationCenter.default.addObserver(self, selector: #selector(LaunchViewController.deepLinkControllerLaunchGame(with:)), name: .deepLinkControllerLaunchGame, object: nil) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -35,10 +45,33 @@ class LaunchViewController: UIViewController { self.presentedGameViewController = true - self.gameViewController.performSegue(withIdentifier: "showInitialGamesViewController", sender: nil) - self.transitionCoordinator?.animate(alongsideTransition: nil, completion: { (context) in + func showGameViewController() + { self.view.bringSubview(toFront: self.gameViewContainerView) - }) + + self.setNeedsStatusBarAppearanceUpdate() + + if #available(iOS 11.0, *) + { + self.setNeedsUpdateOfHomeIndicatorAutoHidden() + } + } + + if let game = self.applicationLaunchDeepLinkGame + { + self.gameViewController.game = game + + UIView.transition(with: self.view, duration: 0.3, options: [.transitionCrossDissolve], animations: { + showGameViewController() + }, completion: nil) + } + else + { + self.gameViewController.performSegue(withIdentifier: "showInitialGamesViewController", sender: nil) + self.transitionCoordinator?.animate(alongsideTransition: nil, completion: { (context) in + showGameViewController() + }) + } } } @@ -49,3 +82,15 @@ class LaunchViewController: UIViewController self.gameViewController = segue.destination as! GameViewController } } + +private extension LaunchViewController +{ + @objc func deepLinkControllerLaunchGame(with notification: Notification) + { + guard !self.presentedGameViewController else { return } + + guard let game = notification.userInfo?[DeepLink.Key.game] as? Game else { return } + + self.applicationLaunchDeepLinkGame = game + } +} diff --git a/Delta/Pause Menu/Save States/SaveStatesViewController.swift b/Delta/Pause Menu/Save States/SaveStatesViewController.swift index 75ad69d..f9ab2c0 100644 --- a/Delta/Pause Menu/Save States/SaveStatesViewController.swift +++ b/Delta/Pause Menu/Save States/SaveStatesViewController.swift @@ -130,11 +130,11 @@ extension SaveStatesViewController self.navigationController?.toolbar.barStyle = .blackTranslucent self.updateTheme() - } + } - override func viewDidDisappear(_ animated: Bool) + override func viewWillDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) + super.viewWillDisappear(animated) self.resetEmulatorCoreIfNeeded() } diff --git a/Delta/Settings/App Icon Shortcuts/AppIconShortcutsViewController.swift b/Delta/Settings/App Icon Shortcuts/AppIconShortcutsViewController.swift new file mode 100644 index 0000000..3ea35a2 --- /dev/null +++ b/Delta/Settings/App Icon Shortcuts/AppIconShortcutsViewController.swift @@ -0,0 +1,355 @@ +// +// AppIconShortcutsViewController.swift +// Delta +// +// Created by Riley Testut on 12/19/17. +// Copyright © 2017 Riley Testut. All rights reserved. +// + +import UIKit + +import DeltaCore + +import Roxas + +@objc(SwitchTableViewCell) +private class SwitchTableViewCell: UITableViewCell +{ + @IBOutlet var switchView: UISwitch! +} + +class AppIconShortcutsViewController: UITableViewController +{ + private lazy var dataSource = RSTCompositeTableViewPrefetchingDataSource(dataSources: [self.modeDataSource, self.shortcutsDataSource, self.gamesDataSource]) + private let modeDataSource = RSTDynamicTableViewDataSource() + private let shortcutsDataSource = RSTArrayTableViewPrefetchingDataSource(items: []) + private let gamesDataSource = RSTFetchedResultsTableViewPrefetchingDataSource(fetchedResultsController: NSFetchedResultsController()) + + required init?(coder aDecoder: NSCoder) + { + super.init(coder: aDecoder) + + self.prepareDataSource() + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.tableView.register(GameTableViewCell.nib!, forCellReuseIdentifier: RSTCellContentGenericCellIdentifier) + + if #available(iOS 11, *) + { + self.navigationItem.searchController = self.gamesDataSource.searchController + self.navigationItem.hidesSearchBarWhenScrolling = false + } + else + { + self.tableView.tableHeaderView = self.gamesDataSource.searchController.searchBar + } + + self.tableView.dataSource = self.dataSource + self.tableView.allowsSelectionDuringEditing = true + + self.updateShortcuts() + } +} + +private extension AppIconShortcutsViewController +{ + func prepareDataSource() + { + // Mode + self.modeDataSource.numberOfSectionsHandler = { 1 } + self.modeDataSource.numberOfItemsHandler = { _ in 1 } + self.modeDataSource.cellIdentifierHandler = { _ in "SwitchCell" } + + // Shortcuts + self.shortcutsDataSource.items = Settings.gameShortcuts + + // Games + let gamesFetchRequest: NSFetchRequest = Game.fetchRequest() + gamesFetchRequest.returnsObjectsAsFaults = false + gamesFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Game.type, ascending: true), NSSortDescriptor(key: #keyPath(Game.name), ascending: true)] + + let gamesFetchedResultsController = NSFetchedResultsController(fetchRequest: gamesFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(Game.type), cacheName: nil) + self.gamesDataSource.fetchedResultsController = gamesFetchedResultsController + self.gamesDataSource.searchController.searchableKeyPaths = [#keyPath(Game.name)] + + // Data Source + self.dataSource.proxy = self + self.dataSource.cellConfigurationHandler = { [unowned self] (cell, game, indexPath) in + if indexPath.section == 0 + { + self.configureModeCell(cell as! SwitchTableViewCell, for: indexPath) + } + else + { + self.configureGameCell(cell as! GameTableViewCell, with: game, for: indexPath) + } + } + self.dataSource.prefetchHandler = { (game, indexPath, completionHandler) in + guard indexPath.section > 0 else { return nil } + + guard let artworkURL = game.artworkURL else { return nil } + + let imageOperation = LoadImageURLOperation(url: artworkURL) + imageOperation.resultHandler = { (image, error) in + completionHandler(image, error) + } + + return imageOperation + } + self.dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + guard indexPath.section > 0 else { return } + + guard let image = image else { return } + + let cell = cell as! GameTableViewCell + + let artworkDisplaySize = AVMakeRect(aspectRatio: image.size, insideRect: cell.artworkImageView.bounds) + let offset = (cell.artworkImageView.bounds.width - artworkDisplaySize.width) / 2 + + // Offset artworkImageViewLeadingConstraint and artworkImageViewTrailingConstraint to right-align artworkImageView + cell.artworkImageViewLeadingConstraint.constant += offset + cell.artworkImageViewTrailingConstraint.constant -= offset + + cell.artworkImageView.image = image + cell.artworkImageView.superview?.layoutIfNeeded() + } + self.dataSource.rowAnimation = .fade + } + + func configureModeCell(_ cell: SwitchTableViewCell, for indexPath: IndexPath) + { + cell.textLabel?.text = NSLocalizedString("Recently Played Games", comment: "") + cell.textLabel?.backgroundColor = .clear + + cell.switchView.isOn = (Settings.gameShortcutsMode == .recent) + cell.switchView.onTintColor = self.view.tintColor + } + + func configureGameCell(_ cell: GameTableViewCell, with game: Game, for indexPath: IndexPath) + { + cell.nameLabel.textColor = .darkText + cell.backgroundColor = .white + + cell.nameLabel.text = game.name + cell.artworkImageView.image = #imageLiteral(resourceName: "BoxArt") + + cell.artworkImageViewLeadingConstraint.constant = 15 + cell.artworkImageViewTrailingConstraint.constant = 15 + + cell.separatorInset.left = cell.nameLabel.frame.minX + + cell.selectedBackgroundView = nil + + switch (indexPath.section, Settings.gameShortcutsMode) + { + case (1, _): + cell.selectionStyle = .none + cell.contentView.alpha = 1.0 + + case (2..., .recent): + cell.selectionStyle = .none + cell.contentView.alpha = 0.3 + + case (2..., .manual): + cell.selectionStyle = .gray + cell.contentView.alpha = 1.0 + + default: break + } + } +} + +private extension AppIconShortcutsViewController +{ + func updateShortcuts() + { + switch Settings.gameShortcutsMode + { + case .recent: + let fetchRequest = Game.recentlyPlayedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + + do + { + let games = try DatabaseManager.shared.viewContext.fetch(fetchRequest) + self.shortcutsDataSource.setItems(games, with: []) + } + catch + { + print(error) + } + + self.tableView.setEditing(false, animated: true) + + case .manual: self.tableView.setEditing(true, animated: true) + } + + Settings.gameShortcuts = self.shortcutsDataSource.items + } + + func addShortcut(for game: Game) + { + guard self.shortcutsDataSource.items.count < 4 else { return } + + guard !self.shortcutsDataSource.items.contains(game) else { return } + + // No need to adjust destinationIndexPath, since it forwards change directly to table view. + let destinationIndexPath = IndexPath(row: self.shortcutsDataSource.items.count, section: 1) + + let insertion = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: destinationIndexPath) + insertion.rowAnimation = .fade + + var shortcuts = self.shortcutsDataSource.items + shortcuts.insert(game, at: destinationIndexPath.row) + self.shortcutsDataSource.setItems(shortcuts, with: [insertion]) + + self.updateShortcuts() + } +} + +private extension AppIconShortcutsViewController +{ + @IBAction func switchGameShortcutsMode(with sender: UISwitch) + { + if sender.isOn + { + Settings.gameShortcutsMode = .recent + } + else + { + Settings.gameShortcutsMode = .manual + } + + self.tableView.beginUpdates() + + self.updateShortcuts() + self.tableView.reloadSections(IndexSet(integersIn: 0 ..< self.tableView.numberOfSections), with: .fade) + + self.tableView.endUpdates() + } +} + +extension AppIconShortcutsViewController +{ + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat + { + guard indexPath.section == 0 else { return super.tableView(tableView, heightForRowAt: indexPath) } + + return 44 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? + { + switch section + { + case 0: return nil + case 1: return NSLocalizedString("Shortcuts", comment: "") + default: + let gameType = GameType(rawValue: self.gamesDataSource.fetchedResultsController.sections![section - 2].name) + + let system = System(gameType: gameType)! + return system.localizedName + } + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? + { + switch (section, Settings.gameShortcutsMode) + { + case (0, .recent): return NSLocalizedString("Your most recently played games will appear as shortcuts when 3D touching the app icon.", comment: "") + case (0, .manual): return NSLocalizedString("The games you've selected below will appear as shortcuts when 3D touching the app icon.", comment: "") + case (1, .recent): return " " // Return non-empty string since empty string changes vertical offset of section for some reason. + case (1, .manual): return NSLocalizedString("You may have up to 4 shortcuts.", comment: "") + + default: return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + guard indexPath.section > 1 else { return } + + guard Settings.gameShortcutsMode == .manual else { return } + + tableView.deselectRow(at: indexPath, animated: true) + + let game = self.dataSource.item(at: indexPath) + self.addShortcut(for: game) + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) + { + switch editingStyle + { + case .none: break + case .delete: + let deletion = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil) + deletion.rowAnimation = .fade + + var shortcuts = self.shortcutsDataSource.items + shortcuts.remove(at: indexPath.row) // No need to adjust indexPath, since it forwards change directly to table view. + self.shortcutsDataSource.setItems(shortcuts, with: [deletion]) + + case .insert: + let game = self.dataSource.item(at: indexPath) + self.addShortcut(for: game) + } + + self.updateShortcuts() + } + + override func tableView(_ tableView: UITableView, titleForDeleteConfirmationButtonForRowAt indexPath: IndexPath) -> String? + { + return NSLocalizedString("Remove", comment: "") + } + + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCellEditingStyle + { + switch indexPath.section + { + case 1: return .delete + case 2...: return .insert + default: return .none + } + } + + override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool + { + return false + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool + { + return (indexPath.section == 1) + } + + override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath + { + let indexPath: IndexPath + + switch proposedDestinationIndexPath.section + { + case 0: indexPath = IndexPath(row: 0, section: 1) + case 1: indexPath = proposedDestinationIndexPath + default: indexPath = IndexPath(row: self.shortcutsDataSource.items.count - 1, section: 1) + } + + return indexPath + } + + override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) + { + var items = self.shortcutsDataSource.items + + let game = items.remove(at: sourceIndexPath.row) + items.insert(game, at: destinationIndexPath.row) + + self.shortcutsDataSource.items = items + + self.updateShortcuts() + } +} diff --git a/Delta/Settings/Settings.swift b/Delta/Settings/Settings.swift index ed097f7..8460403 100644 --- a/Delta/Settings/Settings.swift +++ b/Delta/Settings/Settings.swift @@ -35,6 +35,15 @@ extension Settings } } +extension Settings +{ + enum GameShortcutsMode: String + { + case recent + case manual + } +} + struct Settings { /// Controllers @@ -66,9 +75,54 @@ struct Settings } } + static var gameShortcutsMode: GameShortcutsMode { + set { UserDefaults.standard.gameShortcutsMode = newValue.rawValue } + get { + let mode = GameShortcutsMode(rawValue: UserDefaults.standard.gameShortcutsMode) ?? .recent + return mode + } + } + + static var gameShortcuts: [Game] { + set { + let identifiers = newValue.map { $0.identifier } + UserDefaults.standard.gameShortcutIdentifiers = identifiers + + let shortcuts = newValue.map { UIApplicationShortcutItem(localizedTitle: $0.name, action: .launchGame(identifier: $0.identifier)) } + + DispatchQueue.main.async { + UIApplication.shared.shortcutItems = shortcuts + } + } + get { + let identifiers = UserDefaults.standard.gameShortcutIdentifiers + + do + { + let fetchRequest: NSFetchRequest = Game.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(Game.identifier), identifiers) + fetchRequest.returnsObjectsAsFaults = false + + let games = try DatabaseManager.shared.viewContext.fetch(fetchRequest).sorted(by: { (game1, game2) -> Bool in + let index1 = identifiers.index(of: game1.identifier)! + let index2 = identifiers.index(of: game2.identifier)! + return index1 < index2 + }) + + return games + } + catch + { + print(error) + } + + return [] + } + } + static func registerDefaults() { - let defaults = [#keyPath(UserDefaults.translucentControllerSkinOpacity): 0.7] + let defaults = [#keyPath(UserDefaults.translucentControllerSkinOpacity): 0.7, #keyPath(UserDefaults.gameShortcutsMode): GameShortcutsMode.recent.rawValue] as [String : Any] UserDefaults.standard.register(defaults: defaults) } @@ -163,4 +217,7 @@ private extension UserDefaults { @NSManaged var translucentControllerSkinOpacity: CGFloat @NSManaged var previousGameCollectionIdentifier: String? + + @NSManaged var gameShortcutsMode: String + @NSManaged var gameShortcutIdentifiers: [String] } diff --git a/Delta/Settings/SettingsViewController.swift b/Delta/Settings/SettingsViewController.swift index 94476f6..12377ca 100644 --- a/Delta/Settings/SettingsViewController.swift +++ b/Delta/Settings/SettingsViewController.swift @@ -19,6 +19,7 @@ private extension SettingsViewController case controllers case controllerSkins case controllerOpacity + case threeDTouch } enum Segue: String @@ -130,6 +131,15 @@ private extension SettingsViewController let percentage = String(format: "%.f", Settings.translucentControllerSkinOpacity * 100) + "%" self.controllerOpacityLabel.text = percentage } + + func isSectionHidden(_ section: Section) -> Bool + { + switch section + { + case .threeDTouch: return self.view.traitCollection.forceTouchCapability != .available + default: return false + } + } } private extension SettingsViewController @@ -183,7 +193,15 @@ extension SettingsViewController { case .controllers: return 1 // Temporarily hide other controller indexes until controller logic is finalized case .controllerSkins: return System.supportedSystems.count - default: return super.tableView(tableView, numberOfRowsInSection: sectionIndex) + default: + if isSectionHidden(section) + { + return 0 + } + else + { + return super.tableView(tableView, numberOfRowsInSection: sectionIndex) + } } } @@ -226,6 +244,64 @@ extension SettingsViewController case Section.controllers: self.performSegue(withIdentifier: Segue.controllers.rawValue, sender: cell) case Section.controllerSkins: self.performSegue(withIdentifier: Segue.controllerSkins.rawValue, sender: cell) case Section.controllerOpacity: break + case Section.threeDTouch: break + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? + { + let section = Section(rawValue: section)! + + if isSectionHidden(section) + { + return nil + } + else + { + return super.tableView(tableView, titleForHeaderInSection: section.rawValue) + } + } + + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? + { + let section = Section(rawValue: section)! + + if isSectionHidden(section) + { + return nil + } + else + { + return super.tableView(tableView, titleForFooterInSection: section.rawValue) + } + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat + { + let section = Section(rawValue: section)! + + if isSectionHidden(section) + { + return 1 + } + else + { + return super.tableView(tableView, heightForHeaderInSection: section.rawValue) + } + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat + { + let section = Section(rawValue: section)! + + if isSectionHidden(section) + { + return 1 + } + else + { + return super.tableView(tableView, heightForFooterInSection: section.rawValue) } } } diff --git a/External/Roxas b/External/Roxas index 97b3a7a..a903b12 160000 --- a/External/Roxas +++ b/External/Roxas @@ -1 +1 @@ -Subproject commit 97b3a7ab05ee320d3c96eadc3cc69c38d10ec206 +Subproject commit a903b123e1136d77c8b4f4b0e5f78700160f3e97