[Features] Supports @Feature-specific settings via @Option property wrapper
This commit is contained in:
parent
415450a943
commit
4d30ef2929
@ -177,6 +177,8 @@
|
||||
D539104029E88BC40006B350 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D539103F29E88BC40006B350 /* ContentView.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 */; };
|
||||
D55C469129E7631000EA6DE9 /* AnyOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55C469029E7631000EA6DE9 /* AnyOption.swift */; };
|
||||
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 */; };
|
||||
@ -189,6 +191,7 @@
|
||||
D5D7C1FD29E60EEF00663793 /* OptionalProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D532B8BC29E5EDAD009EE27C /* OptionalProtocol.swift */; };
|
||||
D5D7C20129E60F2000663793 /* LocalizedOptionValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C1E829E5FCDE00663793 /* LocalizedOptionValue.swift */; };
|
||||
D5D7C20229E60F2000663793 /* OptionValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C1E629E5F90200663793 /* OptionValue.swift */; };
|
||||
D5D7C20329E60F2000663793 /* Option.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58F39C529E0A473008B4100 /* Option.swift */; };
|
||||
D5D7C20429E60F2000663793 /* Feature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A9BFFD29DDECF100A8D610 /* Feature.swift */; };
|
||||
D5D7C20829E616CF00663793 /* FeatureContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C20729E616CF00663793 /* FeatureContainer.swift */; };
|
||||
D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */; };
|
||||
@ -415,9 +418,12 @@
|
||||
D539103F29E88BC40006B350 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.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>"; };
|
||||
D55C469029E7631000EA6DE9 /* AnyOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyOption.swift; sourceTree = "<group>"; };
|
||||
D586496F297734280081477E /* CheatMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatMetadata.swift; sourceTree = "<group>"; };
|
||||
D586497129774ABD0081477E /* CheatBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBase.swift; sourceTree = "<group>"; };
|
||||
D5864977297756CE0081477E /* CheatBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBaseView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -1024,6 +1030,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D5A9BFFD29DDECF100A8D610 /* Feature.swift */,
|
||||
D58F39C529E0A473008B4100 /* Option.swift */,
|
||||
D517F6BB29E737F5000D14D0 /* Types */,
|
||||
D5D7C1FE29E60EF700663793 /* Protocols */,
|
||||
D5D7C1F829E60E8600663793 /* Extensions */,
|
||||
@ -1043,6 +1050,8 @@
|
||||
D5D7C1FE29E60EF700663793 /* Protocols */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D55C468E29E761C000EA6DE9 /* AnyFeature.swift */,
|
||||
D55C469029E7631000EA6DE9 /* AnyOption.swift */,
|
||||
D5D7C20729E616CF00663793 /* FeatureContainer.swift */,
|
||||
D532B8BC29E5EDAD009EE27C /* OptionalProtocol.swift */,
|
||||
);
|
||||
@ -1530,10 +1539,13 @@
|
||||
D5D7C20829E616CF00663793 /* FeatureContainer.swift in Sources */,
|
||||
D517F6BA29E730DA000D14D0 /* SettingsName.swift in Sources */,
|
||||
D5D7C20429E60F2000663793 /* Feature.swift in Sources */,
|
||||
D55C469129E7631000EA6DE9 /* AnyOption.swift in Sources */,
|
||||
D5D7C1FD29E60EEF00663793 /* OptionalProtocol.swift in Sources */,
|
||||
D55C468F29E761C000EA6DE9 /* AnyFeature.swift in Sources */,
|
||||
D5D7C20129E60F2000663793 /* LocalizedOptionValue.swift in Sources */,
|
||||
D54F710429E89DFC009C069A /* NotificationName+Settings.swift in Sources */,
|
||||
D54F710229E89DCB009C069A /* SettingsUserInfoKey.swift in Sources */,
|
||||
D5D7C20329E60F2000663793 /* Option.swift in Sources */,
|
||||
D5D7C20229E60F2000663793 /* OptionValue.swift in Sources */,
|
||||
D5D7C1F929E60EA500663793 /* UserDefaults+OptionValues.swift in Sources */,
|
||||
);
|
||||
|
||||
@ -9,8 +9,13 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct EmptyOptions
|
||||
{
|
||||
public init() {}
|
||||
}
|
||||
|
||||
@propertyWrapper @dynamicMemberLookup
|
||||
public final class Feature
|
||||
public final class Feature<Options>: _AnyFeature
|
||||
{
|
||||
public let name: LocalizedStringKey
|
||||
public let description: LocalizedStringKey?
|
||||
@ -36,13 +41,55 @@ public final class Feature
|
||||
}
|
||||
}
|
||||
|
||||
public var wrappedValue: Feature {
|
||||
public var wrappedValue: some Feature {
|
||||
return self
|
||||
}
|
||||
|
||||
public init(name: LocalizedStringKey, description: LocalizedStringKey? = nil)
|
||||
private var options: Options
|
||||
|
||||
public init(name: LocalizedStringKey, description: LocalizedStringKey? = nil, options: Options = EmptyOptions())
|
||||
{
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.options = options
|
||||
|
||||
self.prepareOptions()
|
||||
}
|
||||
|
||||
// Use `KeyPath` instead of `WritableKeyPath` as parameter to allow accessing projected property wrappers.
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<Options, T>) -> T {
|
||||
get {
|
||||
options[keyPath: keyPath]
|
||||
}
|
||||
set {
|
||||
guard let writableKeyPath = keyPath as? WritableKeyPath<Options, T> else { return }
|
||||
options[keyPath: writableKeyPath] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Feature
|
||||
{
|
||||
var allOptions: [any AnyOption] {
|
||||
let features = Mirror(reflecting: self.options).children.compactMap { (child) -> (any AnyOption)? in
|
||||
let feature = child.value as? (any AnyOption)
|
||||
return feature
|
||||
}
|
||||
return features
|
||||
}
|
||||
}
|
||||
|
||||
private extension Feature
|
||||
{
|
||||
func prepareOptions()
|
||||
{
|
||||
// Update option keys + feature
|
||||
for case (let key?, let option as any _AnyOption) in Mirror(reflecting: self.options).children
|
||||
{
|
||||
// Remove leading underscore.
|
||||
let sanitizedKey = key.dropFirst()
|
||||
option.key = String(sanitizedKey)
|
||||
option.feature = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
DeltaFeatures/Option.swift
Normal file
79
DeltaFeatures/Option.swift
Normal file
@ -0,0 +1,79 @@
|
||||
//
|
||||
// Option.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/7/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@propertyWrapper
|
||||
public class Option<Value: OptionValue>: _AnyOption
|
||||
{
|
||||
// Assigned to property name.
|
||||
public internal(set) var key: String = ""
|
||||
|
||||
// Used for `SettingsUserInfoKey.name` value in .settingsDidChange notification.
|
||||
public var settingsKey: SettingsName {
|
||||
guard let feature = self.feature else { return SettingsName(rawValue: self.key) }
|
||||
|
||||
let defaultsKey = feature.key + "_" + self.key
|
||||
return SettingsName(rawValue: defaultsKey)
|
||||
}
|
||||
|
||||
internal weak var feature: (any AnyFeature)?
|
||||
|
||||
private let defaultValue: Value
|
||||
|
||||
/// @propertyWrapper
|
||||
public var projectedValue: some Option {
|
||||
return self
|
||||
}
|
||||
|
||||
public var wrappedValue: Value {
|
||||
get {
|
||||
do {
|
||||
let wrappedValue = try UserDefaults.standard.optionValue(forKey: self.settingsKey.rawValue, type: Value.self)
|
||||
return wrappedValue ?? self.defaultValue
|
||||
}
|
||||
catch {
|
||||
print("[ALTLog] Failed to read option value for key \(self.settingsKey.rawValue).", error)
|
||||
return self.defaultValue
|
||||
}
|
||||
}
|
||||
set {
|
||||
Task { @MainActor in
|
||||
// Delay to avoid "Publishing changes from within view updates is not allowed" runtime warning.
|
||||
(self.feature?.objectWillChange as? ObservableObjectPublisher)?.send()
|
||||
}
|
||||
|
||||
do {
|
||||
try UserDefaults.standard.setOptionValue(newValue, forKey: self.settingsKey.rawValue)
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [SettingsUserInfoKey.name: self.settingsKey, SettingsUserInfoKey.value: newValue])
|
||||
}
|
||||
catch {
|
||||
print("[ALTLog] Failed to set option value for key \(self.settingsKey.rawValue).", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Optional
|
||||
public init(wrappedValue: Value)
|
||||
{
|
||||
self.defaultValue = wrappedValue
|
||||
}
|
||||
|
||||
// Optional, default = nil
|
||||
public init() where Value: OptionalProtocol
|
||||
{
|
||||
self.defaultValue = Value.none
|
||||
}
|
||||
|
||||
// Optional, default = non-nil
|
||||
public init(wrappedValue: Value) where Value: OptionalProtocol
|
||||
{
|
||||
self.defaultValue = wrappedValue
|
||||
}
|
||||
}
|
||||
38
DeltaFeatures/Protocols/AnyFeature.swift
Normal file
38
DeltaFeatures/Protocols/AnyFeature.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// AnyFeature.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/12/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@dynamicMemberLookup
|
||||
public protocol AnyFeature<Options>: ObservableObject, Identifiable
|
||||
{
|
||||
associatedtype Options = EmptyOptions
|
||||
|
||||
var name: LocalizedStringKey { get }
|
||||
var description: LocalizedStringKey? { get }
|
||||
|
||||
var key: String { get }
|
||||
var settingsKey: SettingsName { get }
|
||||
|
||||
var isEnabled: Bool { get set }
|
||||
|
||||
var allOptions: [any AnyOption] { get }
|
||||
|
||||
subscript<T>(dynamicMember keyPath: KeyPath<Options, T>) -> T { get set }
|
||||
}
|
||||
|
||||
extension AnyFeature
|
||||
{
|
||||
public var id: String { self.key }
|
||||
}
|
||||
|
||||
// Don't expose `key` setter via AnyFeature protocol.
|
||||
internal protocol _AnyFeature: AnyFeature
|
||||
{
|
||||
var key: String { get set }
|
||||
}
|
||||
41
DeltaFeatures/Protocols/AnyOption.swift
Normal file
41
DeltaFeatures/Protocols/AnyOption.swift
Normal file
@ -0,0 +1,41 @@
|
||||
//
|
||||
// AnyOption.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/12/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public protocol AnyOption<Value>: AnyObject, Identifiable
|
||||
{
|
||||
associatedtype Value: OptionValue
|
||||
|
||||
var key: String { get }
|
||||
var settingsKey: SettingsName { get }
|
||||
|
||||
var value: Value { get set }
|
||||
}
|
||||
|
||||
extension AnyOption
|
||||
{
|
||||
public var id: String { self.key }
|
||||
}
|
||||
|
||||
// Don't expose `feature` or `key` setters via AnyOption protocol.
|
||||
internal protocol _AnyOption: AnyOption
|
||||
{
|
||||
var key: String { get set }
|
||||
var feature: (any AnyFeature)? { get set }
|
||||
|
||||
var wrappedValue: Value { get set }
|
||||
}
|
||||
|
||||
extension _AnyOption
|
||||
{
|
||||
public var value: Value {
|
||||
get { self.wrappedValue }
|
||||
set { self.wrappedValue = newValue }
|
||||
}
|
||||
}
|
||||
@ -15,9 +15,9 @@ public protocol FeatureContainer
|
||||
|
||||
public extension FeatureContainer
|
||||
{
|
||||
var allFeatures: [Feature] {
|
||||
let features = Mirror(reflecting: self).children.compactMap { (child) -> Feature? in
|
||||
let feature = child.value as? Feature
|
||||
var allFeatures: [any AnyFeature] {
|
||||
let features = Mirror(reflecting: self).children.compactMap { (child) -> (any AnyFeature)? in
|
||||
let feature = child.value as? any AnyFeature
|
||||
return feature
|
||||
}
|
||||
return features
|
||||
@ -26,7 +26,7 @@ public extension FeatureContainer
|
||||
func prepareFeatures()
|
||||
{
|
||||
// Assign keys to property names.
|
||||
for case (let key?, let feature as Feature) in Mirror(reflecting: self).children
|
||||
for case (let key?, let feature as any _AnyFeature) in Mirror(reflecting: self).children
|
||||
{
|
||||
// Remove leading underscore.
|
||||
let sanitizedKey = key.dropFirst()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user