diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 2cdfeb2..2994214 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ D53415A7298C78BC00FD67B1 /* Contributors.plist in Resources */ = {isa = PBXBuildFile; fileRef = D53415A6298C78BC00FD67B1 /* Contributors.plist */; }; D539103229E88B6C0006B350 /* DeltaPreviews.h in Headers */ = {isa = PBXBuildFile; fileRef = D539103129E88B6C0006B350 /* DeltaPreviews.h */; settings = {ATTRIBUTES = (Public, ); }; }; D539104029E88BC40006B350 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D539103F29E88BC40006B350 /* ContentView.swift */; }; + D54A4BB329E4D27E004C7D57 /* FeatureDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54A4BB229E4D27E004C7D57 /* FeatureDetailView.swift */; }; D54F710229E89DCB009C069A /* SettingsUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54F710129E89DCB009C069A /* SettingsUserInfoKey.swift */; }; D54F710429E89DFC009C069A /* NotificationName+Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54F710329E89DFC009C069A /* NotificationName+Settings.swift */; }; D55C468F29E761C000EA6DE9 /* AnyFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55C468E29E761C000EA6DE9 /* AnyFeature.swift */; }; @@ -183,7 +184,9 @@ D5864970297734280081477E /* CheatMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586496F297734280081477E /* CheatMetadata.swift */; }; D586497229774ABD0081477E /* CheatBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586497129774ABD0081477E /* CheatBase.swift */; }; D5864978297756CE0081477E /* CheatBaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5864977297756CE0081477E /* CheatBaseView.swift */; }; + D5A817B329DF6C6C00904AFE /* ExperimentalFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */; }; D5A98CE2284EF14B00E023E5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */; }; + D5A9C00329DDED6D00A8D610 /* ExperimentalFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A9C00229DDED6D00A8D610 /* ExperimentalFeaturesView.swift */; }; D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF27629884F8600F21ACF /* CheatDevice.swift */; }; D5D797E6298D946200738869 /* Contributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D797E5298D946200738869 /* Contributor.swift */; }; D5D797E9298DCC7300738869 /* cheatbase.zip in Resources */ = {isa = PBXBuildFile; fileRef = D5D797E7298DC9E200738869 /* cheatbase.zip */; }; @@ -421,6 +424,7 @@ D539102F29E88B6B0006B350 /* DeltaPreviews.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DeltaPreviews.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D539103129E88B6C0006B350 /* DeltaPreviews.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeltaPreviews.h; sourceTree = ""; }; D539103F29E88BC40006B350 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + D54A4BB229E4D27E004C7D57 /* FeatureDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureDetailView.swift; sourceTree = ""; }; D54F710129E89DCB009C069A /* SettingsUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUserInfoKey.swift; sourceTree = ""; }; D54F710329E89DFC009C069A /* NotificationName+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Settings.swift"; sourceTree = ""; }; D55C468E29E761C000EA6DE9 /* AnyFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyFeature.swift; sourceTree = ""; }; @@ -431,8 +435,10 @@ D58F39C529E0A473008B4100 /* Option.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Option.swift; sourceTree = ""; }; D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+OptionValues.swift"; sourceTree = ""; }; D592D6FE29E48FFB008D218A /* OptionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionPickerView.swift; sourceTree = ""; }; + D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeatures.swift; sourceTree = ""; }; D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; D5A9BFFD29DDECF100A8D610 /* Feature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feature.swift; sourceTree = ""; }; + D5A9C00229DDED6D00A8D610 /* ExperimentalFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeaturesView.swift; sourceTree = ""; }; D5A9C01929DDFBDD00A8D610 /* SettingsName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsName.swift; sourceTree = ""; }; D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = ""; }; D5D797E5298D946200738869 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = ""; }; @@ -826,6 +832,7 @@ BF11734E1DA32CEC00047DF8 /* Controllers */, BF1DAD5B1D9F574900E752A7 /* Controller Skins */, BF48F74C219A16C100BC2FC1 /* Syncing */, + D5A9BFFF29DDECF500A8D610 /* Experimental Features */, D5D797E4298D944C00738869 /* Contributors */, ); path = Settings; @@ -944,6 +951,7 @@ BF59428C1E09BCE50051894B /* Importing */, BF930FFB1EB6D6EC00E8DBA0 /* Systems */, BF525EE61FF5F355004AA849 /* Deep Linking */, + D57D795C29F30B1400BB2CF8 /* Experimental Features */, BF5942571E09BB5D0051894B /* Components */, BF696B7E1D9B2AE6009639E0 /* Theming */, BF090CEE1B490C1A00DCAB45 /* Extensions */, @@ -1014,6 +1022,14 @@ path = DeltaPreviews; sourceTree = ""; }; + D57D795C29F30B1400BB2CF8 /* Experimental Features */ = { + isa = PBXGroup; + children = ( + D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */, + ); + path = "Experimental Features"; + sourceTree = ""; + }; D586496E297734060081477E /* Cheats */ = { isa = PBXGroup; children = ( @@ -1026,6 +1042,15 @@ path = Cheats; sourceTree = ""; }; + D5A9BFFF29DDECF500A8D610 /* Experimental Features */ = { + isa = PBXGroup; + children = ( + D5A9C00229DDED6D00A8D610 /* ExperimentalFeaturesView.swift */, + D54A4BB229E4D27E004C7D57 /* FeatureDetailView.swift */, + ); + path = "Experimental Features"; + sourceTree = ""; + }; D5D797E4298D944C00738869 /* Contributors */ = { isa = PBXGroup; children = ( @@ -1495,6 +1520,7 @@ BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */, BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */, BF5942941E09BD1A0051894B /* NSManagedObject+Conveniences.swift in Sources */, + D5A9C00329DDED6D00A8D610 /* ExperimentalFeaturesView.swift in Sources */, BF1F45BF21AF676F00EF9895 /* Box.swift in Sources */, BF7AE80A1C2E8C7600B1B5BC /* UIColor+Delta.swift in Sources */, BF797A2D1C2D339F00F1A000 /* UILabel+FontSize.swift in Sources */, @@ -1507,10 +1533,12 @@ BF95E2791E4982A10030E7AD /* GamesDatabaseBrowserViewController.swift in Sources */, D524F4A3273DE9C000D500B2 /* ProcessInfo+JIT.swift in Sources */, BFDCA1E9244F7E1000B8FBDB /* Delta5ToDelta6.xcmappingmodel in Sources */, + D5A817B329DF6C6C00904AFE /* ExperimentalFeatures.swift in Sources */, BFD097211D3A01B8005A44C2 /* SaveStatesViewController.swift in Sources */, BF6EE5EB1F7C5F8F0051AD6C /* GameControllerInputMapping.swift in Sources */, BF8A333421A484A000A42FD4 /* BadgedTableViewCell.swift in Sources */, D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */, + D54A4BB329E4D27E004C7D57 /* FeatureDetailView.swift in Sources */, BF3540021C5DA3D500C1184C /* PauseStoryboardSegue.swift in Sources */, BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */, BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */, diff --git a/Delta/Experimental Features/ExperimentalFeatures.swift b/Delta/Experimental Features/ExperimentalFeatures.swift new file mode 100644 index 0000000..c2d67c3 --- /dev/null +++ b/Delta/Experimental Features/ExperimentalFeatures.swift @@ -0,0 +1,19 @@ +// +// ExperimentalFeatures.swift +// Delta +// +// Created by Riley Testut on 4/6/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import DeltaFeatures + +struct ExperimentalFeatures: FeatureContainer +{ + static let shared = ExperimentalFeatures() + + private init() + { + self.prepareFeatures() + } +} diff --git a/Delta/Settings/Experimental Features/ExperimentalFeaturesView.swift b/Delta/Settings/Experimental Features/ExperimentalFeaturesView.swift new file mode 100644 index 0000000..d233607 --- /dev/null +++ b/Delta/Settings/Experimental Features/ExperimentalFeaturesView.swift @@ -0,0 +1,97 @@ +// +// ExperimentalFeaturesView.swift +// Delta +// +// Created by Riley Testut on 4/5/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import SwiftUI +import Combine + +import DeltaFeatures + +extension ExperimentalFeaturesView +{ + private class ViewModel: ObservableObject + { + @Published + var sortedFeatures: [any AnyFeature] + + init() + { + // Sort features alphabetically by name. + self.sortedFeatures = ExperimentalFeatures.shared.allFeatures.sorted { (featureA, featureB) in + return String(describing: featureA.name) < String(describing: featureB.name) + } + } + } +} + +struct ExperimentalFeaturesView: View +{ + @StateObject + private var viewModel: ViewModel = ViewModel() + + var body: some View { + Form { + Section(content: {}, footer: { + Text("These features have been added by contributors to the open-source Delta project on GitHub and are currently being tested.\n\nYou may encounter bugs when using these features.") + .font(.subheadline) + }) + + ForEach(viewModel.sortedFeatures, id: \.key) { feature in + section(for: feature) + } + } + .listStyle(.insetGrouped) + } + + // Cannot open existential if return type uses concrete type T in non-covariant position (e.g. Box). + // So instead we erase return type to AnyView. + private func section(for feature: T) -> AnyView + { + let section = FeatureSection(feature: feature) + return AnyView(section) + } +} + +extension ExperimentalFeaturesView +{ + static func makeViewController() -> UIHostingController + { + let experimentalFeaturesView = ExperimentalFeaturesView() + + let hostingController = UIHostingController(rootView: experimentalFeaturesView) + hostingController.title = NSLocalizedString("Experimental Features", comment: "") + return hostingController + } +} + +private struct FeatureSection: View +{ + @ObservedObject + var feature: T + + var body: some View { + Section { + NavigationLink(destination: FeatureDetailView(feature: feature)) { + HStack { + Text(feature.name) + Spacer() + + if feature.isEnabled + { + Text("On") + .foregroundColor(.secondary) + } + } + } + } footer: { + if let description = feature.description + { + Text(description) + } + } + } +} diff --git a/Delta/Settings/Experimental Features/FeatureDetailView.swift b/Delta/Settings/Experimental Features/FeatureDetailView.swift new file mode 100644 index 0000000..e07b5c9 --- /dev/null +++ b/Delta/Settings/Experimental Features/FeatureDetailView.swift @@ -0,0 +1,117 @@ +// +// FeatureDetailView.swift +// Delta +// +// Created by Riley Testut on 4/10/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import SwiftUI + +import DeltaFeatures + +struct FeatureDetailView: View +{ + @ObservedObject + var feature: Feature + + var body: some View { + Form { + Section { + Toggle(isOn: $feature.isEnabled.animation()) { + Text(feature.name) + .bold() + } + } footer: { + if let description = feature.description + { + Text(description) + } + } + + if feature.isEnabled + { + ForEach(feature.allOptions, id: \.key) { option in + if let optionView = optionView(option) + { + Section { + optionView + } footer: { + if let description = option.description + { + Text(description) + } + } + } + } + } + } + } + + // Cannot open existential if return type uses concrete type T in non-covariant position (e.g. Box). + // So instead we erase return type to AnyView. + private func optionView(_ option: T) -> AnyView? + { + guard let view = OptionRow(option: option) else { return nil } + return AnyView(view) + } +} + +private struct OptionRow: View where DetailView == Option.DetailView +{ + var name: LocalizedStringKey + var value: any LocalizedOptionValue + var detailView: DetailView + + @State + private var displayInline: Bool = false + + init?(option: Option) + { + // Only show if option has a name, localizable value, and detailView. + guard + let name = option.name, + let value = option.value as? any LocalizedOptionValue, + let detailView = option.detailView() + else { return nil } + + self.name = name + self.value = value + self.detailView = detailView + } + + var body: some View { + VStack { + if displayInline + { + // Display entire view inline. + detailView + } + else + { + let wrappedDetailView = Form { + detailView + } + + NavigationLink(destination: wrappedDetailView) { + HStack { + Text(name) + Spacer() + + value.localizedDescription + .foregroundColor(.secondary) + } + } + .overlay( + // Hack to ensure displayInline preference is in View hierarchy. + detailView + .hidden() + .frame(width: 0, height: 0) + ) + } + } + .onPreferenceChange(DisplayInlineKey.self) { displayInline in + self.displayInline = displayInline + } + } +}