Adds ExperimentalFeaturesView to browse and configure experimental features

This commit is contained in:
Riley Testut 2023-04-21 14:03:10 -05:00
parent 5b4f9ea593
commit bd0c72e847
4 changed files with 261 additions and 0 deletions

View File

@ -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 = "<group>"; };
D539103F29E88BC40006B350 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
D54A4BB229E4D27E004C7D57 /* FeatureDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureDetailView.swift; sourceTree = "<group>"; };
D54F710129E89DCB009C069A /* SettingsUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUserInfoKey.swift; sourceTree = "<group>"; };
D54F710329E89DFC009C069A /* NotificationName+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Settings.swift"; sourceTree = "<group>"; };
D55C468E29E761C000EA6DE9 /* AnyFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyFeature.swift; sourceTree = "<group>"; };
@ -431,8 +435,10 @@
D58F39C529E0A473008B4100 /* Option.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Option.swift; sourceTree = "<group>"; };
D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+OptionValues.swift"; sourceTree = "<group>"; };
D592D6FE29E48FFB008D218A /* OptionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionPickerView.swift; sourceTree = "<group>"; };
D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeatures.swift; sourceTree = "<group>"; };
D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
D5A9BFFD29DDECF100A8D610 /* Feature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feature.swift; sourceTree = "<group>"; };
D5A9C00229DDED6D00A8D610 /* ExperimentalFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeaturesView.swift; sourceTree = "<group>"; };
D5A9C01929DDFBDD00A8D610 /* SettingsName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsName.swift; sourceTree = "<group>"; };
D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = "<group>"; };
D5D797E5298D946200738869 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
D57D795C29F30B1400BB2CF8 /* Experimental Features */ = {
isa = PBXGroup;
children = (
D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */,
);
path = "Experimental Features";
sourceTree = "<group>";
};
D586496E297734060081477E /* Cheats */ = {
isa = PBXGroup;
children = (
@ -1026,6 +1042,15 @@
path = Cheats;
sourceTree = "<group>";
};
D5A9BFFF29DDECF500A8D610 /* Experimental Features */ = {
isa = PBXGroup;
children = (
D5A9C00229DDED6D00A8D610 /* ExperimentalFeaturesView.swift */,
D54A4BB229E4D27E004C7D57 /* FeatureDetailView.swift */,
);
path = "Experimental Features";
sourceTree = "<group>";
};
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 */,

View File

@ -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()
}
}

View File

@ -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<T>).
// So instead we erase return type to AnyView.
private func section<T: AnyFeature>(for feature: T) -> AnyView
{
let section = FeatureSection(feature: feature)
return AnyView(section)
}
}
extension ExperimentalFeaturesView
{
static func makeViewController() -> UIHostingController<some View>
{
let experimentalFeaturesView = ExperimentalFeaturesView()
let hostingController = UIHostingController(rootView: experimentalFeaturesView)
hostingController.title = NSLocalizedString("Experimental Features", comment: "")
return hostingController
}
}
private struct FeatureSection<T: AnyFeature>: 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)
}
}
}
}

View File

@ -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<Feature: AnyFeature>: 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<T>).
// So instead we erase return type to AnyView.
private func optionView<T: AnyOption>(_ option: T) -> AnyView?
{
guard let view = OptionRow(option: option) else { return nil }
return AnyView(view)
}
}
private struct OptionRow<Option: AnyOption, DetailView: View>: 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
}
}
}