diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index b1e00b2..b20b352 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + D55C469029E7631000EA6DE9 /* AnyOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyOption.swift; sourceTree = ""; }; D586496F297734280081477E /* CheatMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatMetadata.swift; sourceTree = ""; }; D586497129774ABD0081477E /* CheatBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBase.swift; sourceTree = ""; }; D5864977297756CE0081477E /* CheatBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBaseView.swift; sourceTree = ""; }; + 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 = ""; }; D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; D5A9BFFD29DDECF100A8D610 /* Feature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feature.swift; sourceTree = ""; }; @@ -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 */, ); diff --git a/DeltaFeatures/Feature.swift b/DeltaFeatures/Feature.swift index 2b0bee7..967b5b6 100644 --- a/DeltaFeatures/Feature.swift +++ b/DeltaFeatures/Feature.swift @@ -9,8 +9,13 @@ import SwiftUI import Combine +public struct EmptyOptions +{ + public init() {} +} + @propertyWrapper @dynamicMemberLookup -public final class Feature +public final class Feature: _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(dynamicMember keyPath: KeyPath) -> T { + get { + options[keyPath: keyPath] + } + set { + guard let writableKeyPath = keyPath as? WritableKeyPath 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 + } } } diff --git a/DeltaFeatures/Option.swift b/DeltaFeatures/Option.swift new file mode 100644 index 0000000..3f828c5 --- /dev/null +++ b/DeltaFeatures/Option.swift @@ -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: _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 + } +} diff --git a/DeltaFeatures/Protocols/AnyFeature.swift b/DeltaFeatures/Protocols/AnyFeature.swift new file mode 100644 index 0000000..46f0999 --- /dev/null +++ b/DeltaFeatures/Protocols/AnyFeature.swift @@ -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: 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(dynamicMember keyPath: KeyPath) -> 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 } +} diff --git a/DeltaFeatures/Protocols/AnyOption.swift b/DeltaFeatures/Protocols/AnyOption.swift new file mode 100644 index 0000000..32177df --- /dev/null +++ b/DeltaFeatures/Protocols/AnyOption.swift @@ -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: 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 } + } +} diff --git a/DeltaFeatures/Protocols/FeatureContainer.swift b/DeltaFeatures/Protocols/FeatureContainer.swift index 7060aab..28bc686 100644 --- a/DeltaFeatures/Protocols/FeatureContainer.swift +++ b/DeltaFeatures/Protocols/FeatureContainer.swift @@ -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()