├── .gitignore ├── LICENSE ├── README.md ├── ToggleUI.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── ToggleUI ├── .gitignore ├── Package.swift ├── Sources │ └── ToggleUI │ │ ├── AnyPublisher.swift │ │ ├── Codable │ │ ├── CodableToggleDecoder.swift │ │ └── CodableToggleProvider.swift │ │ ├── Debug │ │ ├── DebugToggle.swift │ │ └── DebugView.swift │ │ ├── FeatureGroup │ │ ├── AnyFeatureGroup.swift │ │ ├── FeatureGroup.swift │ │ ├── FeatureGroupDecoder.swift │ │ ├── FeatureGroupProperty.swift │ │ └── ObservableFeatureGroup.swift │ │ ├── FeatureToggle │ │ ├── AnyFeatureToggle.swift │ │ ├── FeatureToggle.swift │ │ └── ObservableFeatureToggle.swift │ │ ├── ToggleDecoder │ │ ├── DictionaryToggleDecoder.swift │ │ └── ToggleDecoder.swift │ │ ├── ToggleProvider │ │ ├── ToggleProvider.swift │ │ ├── ToggleProviderResolving.swift │ │ └── UserDefaultsToggleProvider.swift │ │ └── WithDefaults.swift └── Tests │ └── ToggleUITests │ ├── ToggleUITests.swift │ └── Toggles.swift ├── ToggleUIApp ├── ToggleUIApp.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── ToggleUIApp.xcscheme └── ToggleUIApp │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── ContentView.swift │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── SceneDelegate.swift └── debug-view.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | xcuserdata/ 3 | .swiftpm 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ilya Puchka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ToggleUI 2 | 3 | ToggleUI is a library that provides property wrappers to create feature toggles for your app with built-in debug view based on SwiftUI and Combine. 4 | 5 | ## Usage 6 | 7 | The simplest way to define a feature toggle is to use `FeatureToggle` property wrapper: 8 | 9 | ```swift 10 | let userDefaultsProvider = UserDefaultsToggleProvider(userDefaults: .standard) 11 | 12 | struct MyFeatureToggles: WithDefaults { 13 | @FeatureToggle( 14 | key: "isFeatureEnabled", 15 | debugDescription: "Enables a feature", 16 | provider: userDefaultsProvider 17 | ) 18 | var isFeatureEnabled: Bool 19 | } 20 | ``` 21 | 22 | This creates a Boolean feature toggle with a key `isFeatureEnabled`. It requires a `provide` as its parameter (usually accessible globally rather than created in place). There are built-in providers, like `InMemoryToggleProvider` or `UserDefaultsToggleProvider` and others, but you can as well create your own. 23 | 24 | ## Supported features 25 | 26 | - [Feature Toggles](#featuretoggle) 27 | - [Feature Groups](#FeatureGroup) 28 | - [Codable](#codable-support) 29 | - [DebugView](#debugview) 30 | - [User Interface support](#user-interface-support) 31 | 32 | ## FeatureToggle 33 | 34 | `FeatureToggle` defines a simple feature toggle of Boolean, String, String based Enum or any other Hashable type. Any feature toggle requires at least a key, a default value and a provider. A key can be a string that represents a key path in a KVC style, i.e. `key1.key2.key3` (indexed access to array elements is also supported). 35 | 36 | Default value is inferred from the type of the feature toggle, i.e. it's `false` for Boolean and empty string for String, and is used in case of an error when computing effective value of the feature toggle. 37 | 38 | Wrapped value of the `FeatureToggle` is the effective value of the toggle and is computed lazily. That is, every time you access it the value will be computed by calling a `provider`. This means that the value can change over the runtime of your application if underlying source of values for feature toggles changes. 39 | 40 | Additionally you can provide `debugDescription` and `debugValues` that will be used in DebugView. For enum based values debug values are inferred from `CaseIterable` implementation. 41 | 42 | If you want to implement a toggle of some custom type you can do that by providing a `get` closure. For Boolean, String and Enum types this closure is defined by the library in the extensions which you can define for you own types as well. 43 | 44 | As a matter of fact `FeatureToggle` is a type alias of `ProviderResolvingFeatureToggle` type that defines additional generic type parameter used to access a toggle provider. This way this generic type parameter can be used instead of provider constructor parameter that can be a more convenient way to define feature toggles and immediately distinguish feature toggles backed by different providers. 45 | 46 | For example you can define a type alias that uses `UserDefaultsToggleProvider` like this (this is built-in so you don't have to define it for user defaults provider): 47 | 48 | ```swift 49 | let userDefaultsProvider = UserDefaultsToggleProvider(userDefaults: .standard) 50 | 51 | struct UserDefaultsToggleProviderResolver: ToggleProviderResolving { 52 | static func makeProviderToggleProvider { 53 | userDefaultsProvider 54 | } 55 | } 56 | 57 | typealias UserDefaultsFeatureToggle = ProviderResolvingFeatureToggle 58 | 59 | struct MyFeatureToggles { 60 | @UserDefaultsFeatureToggle(key: "isFeatureEnabled") 61 | var isFeatureEnabled: Bool 62 | } 63 | ``` 64 | 65 | In cases when you want to observe changes of the feature toggle effective value and update the app when these values change you can use `ObservableFeatureToggle` (or `ProviderResolvingObservableFeatureToggle` to specify provider via generic type parameter). The only difference between observable and plain feature toggle is that observable feature toggle wrapped value type is `AnyPublisher`. 66 | 67 | ## FeatureGroup 68 | 69 | Feature group is a collection of related feature toggles. You can use it to enabled/disable the feature and at the same time control some of its additional properties, or to group together feature toggles for smaller features in a large feature of your app. Feature group is defined using `FeatureGroup` type and works pretty much the same as any feature toggle (and in fact is backed by a feature toggle) so can be observed using `ObservableFeatureGroup` and is typealiased to `ProviderResolvingFeatureGroup`. 70 | 71 | Feature group type must implement `FeatureGroupDecodable` protocol that only requires to declare an empty `init()` constructor. Each feature toggle in the group is defined using `FeatureGroupProperty` property wrapper. The key of each property will be combined of its feature group's key and the property's own key. In the following example the key of the `someFeatureProperty` feature toggle will be `config.someFeatureProperty`: 72 | 73 | ```swift 74 | struct Config: FeatureGroupDecodable { 75 | @FeatureGroupProperty(key: "someFeatureProperty") 76 | var someFeatureProperty: String 77 | 78 | init() {} 79 | } 80 | 81 | @FeatureGroup(key: "config") 82 | var config: Config 83 | ``` 84 | 85 | Feature group can contain other feature groups. You should only use `FeatureGroupProperty` for the properties of the feature group. They don't provide observable counterparts. For observing these values use `ObservableFeatureGroup` and `map` operator to create an observer for individual property. 86 | 87 | ## ToggleProvider 88 | 89 | To actually access values of your feature toggles you need a "provider". ToggleUI comes with some built-in providers like `UserDefaultsToggleProvider` and `InMemoryToggleProvider` that use user defaults or in memory storage as the source of values for feature toggles. Each toggle provider must implement `ToggleProvider` protocol. Each toggle provider should define an actual provider and an `override` provider that additionally must implement `ToggleOverriding` protocol that is used to override values at runtime from DebugView. By default `provider` returns `self` and `override` returns an instance of `DefaultToggleProvider` that always returns default value of a feature toggle, returns `false` in `hasValue` method and does nothing in `setValue` method (effectively not providing any override functionality). With that you can combine different providers together, i.e. you can use user defaults provider as an actual provider and in-memory provider as override provider and combine them using built-in `OverridableToggleProvider`: 90 | 91 | ```swift 92 | var currentProvider: ToggleProvider = OverridableToggleProvider( 93 | provider: inMemoryProvider, 94 | override: userDefaultsProvider 95 | ) 96 | ``` 97 | 98 | ## ToggleDecoder 99 | 100 | Toggle providers use toggle decoders to decode values from underlying storage, which can be in-memory dictionary, user defaults, remote JSON or any other storage. ToggleUI comes with a built-in `DictionaryToggleDecoder` that implements exception-safe KVC access to values stored in string-keyed dictionary. This decoder is used by `InMemoryToggleProvider` and `UserDefaultsToggleProvider`. 101 | 102 | If you are using a 3rd party configuration framework, i.e. Optimizely or Firebase, you will need to implement both provider and a decoder that would use these tools SDKs types to get values from these services. 103 | 104 | ## Codable support 105 | 106 | If you are using your own configuration service you can implement a `ToggleProvider` for it using built-in `Codable` support. For that your provider must implement `CodableToggleProvider` protocol that requires a `dataPublisher` property. This protocol by default uses built-in `CodableToggleDecoder` that can lazily decode values from the data. Here is an example of how to implement such provider using URLSession. 107 | 108 | 1. create a data publisher: 109 | 110 | ```swift 111 | class ConfigPublisher { 112 | var bag = Set() 113 | let publisher: AnyPublisher, Never> 114 | 115 | init(session: URLSession = URLSession.shared) { 116 | let subject = CurrentValueSubject?, Never>(nil) 117 | session 118 | .dataTaskPublisher(for: URL(string: "...")!) 119 | .sink( 120 | // do not complete publisher to allow receiving future values, i.e. from override provider 121 | receiveCompletion: { _ in }, 122 | receiveValue: { data, _ in subject.send(.success(data)) } 123 | ).store(in: &bag) 124 | self.publisher = subject 125 | // filter out initial and further nil data values 126 | .filter { $0 != nil }.map { $0! } 127 | .eraseToAnyPublisher() 128 | } 129 | } 130 | 131 | // use a global shared instance to ensure that network is accessed only once for all feature toggles 132 | let DataPublisher: AnyPublisher, Never> = ConfigPublisher() 133 | ``` 134 | 135 | 2. create a toggle provider: 136 | 137 | ```swift 138 | class URLSessionRemoteToggleProvider: CodableToggleProvider { 139 | var name: String = "Remote Config" 140 | // decode keys right from the root 141 | typealias DecoderCaptured = ToggleUI.DecoderCaptured 142 | 143 | let dataPublisher = DataPublisher.publisher 144 | } 145 | 146 | let urlConfigProvider = OverridableToggleProvider( 147 | provider: URLSessionRemoteToggleProvider(), 148 | override: userDefaultsProvider 149 | ) 150 | ``` 151 | 152 | `DecoderCaptured` associated type can be used to simplify access to the values that are namespaced under a certain key, or to define separate providers that access values from different namespaces. Built-in `DecoderCaptured` implementation simply captures root decoder but you can implement your own types that would capture decoder for arbitrary nested key using `DecoderCapturing` protocol. For example if your feature toggle values are namespaced under the `config` key in the JSON returned by your configuration service API then you can implement your provider like this: 153 | 154 | ```swift 155 | struct ConfigDecoderCaptured: DecoderCapturing { 156 | enum CodingKeys: String, CodingKey { 157 | case decoder = "config" 158 | } 159 | 160 | // Built-in `DecoderCaptured` type can be used as a property wrapper 161 | // to capture decoder by a key of a wrapped property 162 | // Here it will be `config` key as it's the raw value of associated coding key 163 | @DecoderCaptured var decoder: Decoder 164 | 165 | // Alternatively you can define `init(from: Decoder)` and capture decoder 166 | // using `superDecoder(forKey:)` method if your JSON structure is less trivial 167 | /** 168 | init(from decoder: Decoder) throws { 169 | let values = try decoder.container(keyedBy: CodingKeys.self) 170 | self.decoder = try values.superDecoder(forKey: .decoder) 171 | } 172 | */ 173 | } 174 | 175 | class URLSessionRemoteToggleProvider: CodableToggleProvider { 176 | // Use custom associated type insead of built-in 177 | typealias DecoderCaptured = ConfigDecoderCaptured 178 | 179 | ... 180 | } 181 | ``` 182 | 183 | This way provider will capture a decoder for `config` key rather than a root decoder. With that you won't need to use `config.` prefix in the keys of each feature toggle. 184 | 185 | 186 | ## DebugView 187 | 188 | ToggleUI comes with a built-in `DebugView` implemented in SwiftUI. It allows to inspect and override values of feature toggles at runtime. 189 | 190 | 191 | 192 | For `DebugView` to discover your feature toggles you should define them inside a type that implements `WithDefaults` protocol and instantiate its value before accessing `DebugView`. When this instance is instantiated the framework uses `Mirror` to access all of its properties wrapped with `FeatureToggle` or `FeatureGroup` property wrappers and stores them in `ToggleUI.debugToggles` global variable that `DebugView` then uses. `WithDefaults` protocol also allows to override default values of some feature toggles that can be useful to enforce their values in debug builds. 193 | 194 | ```swift 195 | struct MyFeatureGroup: FeatureGroupDecodable { 196 | @FeatureGroupProperty(key: "featureA") 197 | var featureA: Bool 198 | 199 | init() {} 200 | } 201 | 202 | struct Toggles: WithDefaults { 203 | @UserDefaultsFeatureToggle(key: "featureA") 204 | var featureA: Bool 205 | 206 | @UserDefaultsFeatureToggle(key: "featureB") 207 | var featureB: Bool 208 | 209 | @UserDefaultsFeatureGroup(key: "featureGroup") 210 | var featureGroup: MyFeatureGroup 211 | } 212 | 213 | let toggles = Toggles().withDefaults { toggles in 214 | #if DEBUG 215 | // change the default value 216 | toggles.$featureA.defaultValue = true 217 | toggles.$featureGroup.defaultValue.$featureA = true 218 | 219 | // or override the value 220 | toggles.$featureB.setValue(true) 221 | toggles.$featureGroup.$featureA.setValue(true) 222 | #endif 223 | } 224 | ``` 225 | 226 | ## User interface support 227 | 228 | If you want to provide your users with controls to configure or just view features of your app you can do that using `AnyMutableFeatureToggle`/`AnyMutableFeatureGroup` that allows to override feature toggles values via SwiftUI bindings or `AnyFeatureToggle`/`AnyFeatureGroup` that will only display current values and won't allow changing them. 229 | 230 | For example if you have a simple boolean feature toggle you can display a `Toggle` that will allow user to change its value at runtime: 231 | 232 | ```swift 233 | let userDefaultsProvider = OverridableToggleProvider( 234 | provider: DefaultToggleProvider(), 235 | override: UserDefaultsToggleProvider(userDefaults: .standard) 236 | ) 237 | 238 | struct MyFeatureToggles: WithDefaults { 239 | @FeatureToggle( 240 | key: "isFeatureEnabled", 241 | debugDescription: "Enables a feature", 242 | provider: userDefaultsProvider 243 | ) 244 | var isFeatureEnabled: Bool 245 | } 246 | 247 | let toggles = MyFeatureToggles() 248 | 249 | struct SettingsView: View { 250 | @ObservedObject var featureA = AnyMutableFeatureToggle(toggles.$featureA) 251 | 252 | var body: some View { 253 | Form { 254 | Toggle("Feature A", isOn: featureA.binding) 255 | } 256 | } 257 | } 258 | ``` 259 | 260 | For feature groups you can access bindings for feature group properties like this: 261 | 262 | ```swift 263 | struct MyFeatureGroup: FeatureGroupDecodable { 264 | @FeatureGroupProperty(key: "featureA") 265 | var featureA: Bool 266 | @FeatureGroupProperty(key: "featureB") 267 | var featureB: Bool 268 | 269 | init() {} 270 | } 271 | 272 | struct MyFeatureToggles: WithDefaults { 273 | @FeatureGroup( 274 | key: "featureGroup", 275 | provider: userDefaultsProvider 276 | ) 277 | var featureGroup: MyFeatureGroup 278 | } 279 | 280 | let toggles = MyFeatureToggles() 281 | 282 | struct SettingsView: View { 283 | @ObservedObject var featureGroup = AnyMutableFeatureGroup(toggles.$featureGroup) 284 | 285 | var body: some View { 286 | Form { 287 | Toggle("Feature A", isOn: featureGroup.$featureA) 288 | Toggle("Feature B", isOn: featureGroup.$featureB) 289 | } 290 | } 291 | } 292 | ``` 293 | 294 | As we are using `@ObservedObject` here the state of UI toggles will also get updated if the value of the feature toggles was changed via `DebugView`. 295 | 296 | ## License 297 | 298 | ToggleUI is available under the MIT license. See [LICENSE](LICENSE) for more information. 299 | -------------------------------------------------------------------------------- /ToggleUI.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ToggleUI.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ToggleUI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /ToggleUI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ToggleUI", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 11 | .library( 12 | name: "ToggleUI", 13 | targets: ["ToggleUI"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 22 | .target( 23 | name: "ToggleUI", 24 | dependencies: []), 25 | .testTarget( 26 | name: "ToggleUITests", 27 | dependencies: ["ToggleUI"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/AnyPublisher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | extension AnyPublisher { 5 | struct NoValueError: Error {} 6 | 7 | func single() throws -> Output { 8 | let semaphore = DispatchSemaphore(value: 0) 9 | var result: Result! 10 | var bag = Set() 11 | 12 | self.sink( 13 | receiveCompletion: { (c) in 14 | switch c { 15 | case .finished: 16 | result = .failure(NoValueError()) 17 | case let .failure(error): 18 | result = .failure(error) 19 | } 20 | semaphore.signal() 21 | }, 22 | receiveValue: { (value) in 23 | result = .success(value) 24 | semaphore.signal() 25 | } 26 | ).store(in: &bag) 27 | 28 | semaphore.wait() 29 | 30 | return try result.get() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/Codable/CodableToggleDecoder.swift: -------------------------------------------------------------------------------- 1 | /// Uses `Decoder` to access values. Typically used with CodableToggleProvider. 2 | public struct CodableToggleDecoder: ToggleDecoder { 3 | public var key: String 4 | /// Decoder to decode values with 5 | public let decoder: Decoder 6 | 7 | public init(key: String, decoder: Decoder) { 8 | self.key = key 9 | self.decoder = decoder 10 | } 11 | 12 | public func decode() throws -> Bool { 13 | let container = try decoder.container(keyedBy: CodingKeyPath.self) 14 | return try container.decode(Bool.self, forKeyPath: CodingKeyPath(key)) 15 | } 16 | 17 | public func decode() throws -> String { 18 | let container = try decoder.container(keyedBy: CodingKeyPath.self) 19 | return try container.decode(String.self, forKeyPath: CodingKeyPath(key)) 20 | } 21 | 22 | public func decode(key: String) throws -> Bool { 23 | let container = try decoder.container(keyedBy: CodingKeyPath.self) 24 | let nestedKey = CodingKeyPath(keys: self.key, key) 25 | return try container.decode(Bool.self, forKeyPath: nestedKey) 26 | } 27 | 28 | public func decode(key: String) throws -> String { 29 | let container = try decoder.container(keyedBy: CodingKeyPath.self) 30 | let nestedKey = CodingKeyPath(keys: self.key, key) 31 | return try container.decode(String.self, forKeyPath: nestedKey) 32 | } 33 | } 34 | 35 | /// Coding key that can represent a key path using `.` as a components separator 36 | public struct CodingKeyPath: CodingKey, ExpressibleByStringLiteral, ExpressibleByIntegerLiteral { 37 | public var intValue: Int? 38 | public var stringValue: String 39 | 40 | public init?(intValue: Int) { 41 | self.intValue = intValue 42 | self.stringValue = "\(intValue)" 43 | } 44 | 45 | public init?(stringValue: String) { 46 | self.stringValue = stringValue 47 | self.intValue = Int(stringValue) 48 | } 49 | 50 | public init(stringLiteral value: String) { 51 | self.init(stringValue: value)! 52 | } 53 | 54 | public init(_ value: String) { 55 | self.init(stringValue: value)! 56 | } 57 | 58 | public init(integerLiteral value: Int) { 59 | self.init(intValue: value)! 60 | } 61 | 62 | public init(keys: String...) { 63 | self.init(keys: keys) 64 | } 65 | 66 | public init(keys: [String]) { 67 | self.init(keys.joined()) 68 | } 69 | 70 | /// Returns the first key path component and the rest of the key path 71 | public var head: (CodingKeyPath, CodingKeyPath) { 72 | var keyPath = stringValue.components(separatedBy: ".") 73 | return (CodingKeyPath(keyPath.removeFirst()), CodingKeyPath(keys: keyPath)) 74 | } 75 | 76 | public var isEmpty: Bool { 77 | stringValue.isEmpty 78 | } 79 | 80 | public var path: [String] { 81 | stringValue.components(separatedBy: ".") 82 | } 83 | } 84 | 85 | struct EmptyCodable: Codable {} 86 | 87 | extension UnkeyedDecodingContainer { 88 | /// Gets nested unkeyed container shifted to the index (for array-in-array) 89 | mutating func nestedUnkeyedContainer(at index: Int) throws -> UnkeyedDecodingContainer { 90 | var unkeyedContainer = try nestedUnkeyedContainer() 91 | try unkeyedContainer.advance(to: index) 92 | return unkeyedContainer 93 | } 94 | 95 | mutating func advance(to index: Int) throws { 96 | for _ in 0.. UnkeyedDecodingContainer { 105 | var unkeyedContainer = try nestedUnkeyedContainer(forKey: key) 106 | try unkeyedContainer.advance(to: index) 107 | return unkeyedContainer 108 | } 109 | } 110 | 111 | public extension KeyedDecodingContainer where Key == CodingKeyPath { 112 | func decode(_ type: T.Type, forKeyPath key: CodingKeyPath) throws -> T { 113 | var keyedContainer: Self? = self 114 | var unkeyedContainer: UnkeyedDecodingContainer? = nil 115 | 116 | var (key, keyPath) = key.head 117 | 118 | while !keyPath.isEmpty { 119 | if let index = key.intValue { 120 | unkeyedContainer = try keyedContainer?.nestedUnkeyedContainer(forKey: key, at: index) 121 | ?? unkeyedContainer?.nestedUnkeyedContainer(at: index) 122 | keyedContainer = nil 123 | } else { 124 | keyedContainer = try keyedContainer?.nestedContainer(keyedBy: CodingKeyPath.self, forKey: key) 125 | ?? unkeyedContainer?.nestedContainer(keyedBy: CodingKeyPath.self) 126 | unkeyedContainer = nil 127 | } 128 | (key, keyPath) = keyPath.head 129 | } 130 | 131 | if let c = keyedContainer { 132 | return try c.decode(T.self, forKey: key) 133 | } else if var c = unkeyedContainer { 134 | return try c.decode(T.self) 135 | } else { 136 | fatalError("Should never happen") 137 | } 138 | } 139 | } 140 | 141 | extension Array where Element == String { 142 | func joined() -> String { 143 | self.joined(separator: ".") 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/Codable/CodableToggleProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | public protocol DecoderCapturing: Decodable { 5 | var decoder: Decoder { get } 6 | } 7 | 8 | @propertyWrapper 9 | public struct DecoderCaptured: DecoderCapturing { 10 | public var wrappedValue: Decoder { decoder } 11 | public let decoder: Decoder 12 | 13 | public init(from decoder: Decoder) throws { 14 | self.decoder = decoder 15 | } 16 | } 17 | 18 | extension KeyedDecodingContainer { 19 | public func decode(_: DecoderCaptured.Type, forKey key: Key) throws -> DecoderCaptured { 20 | try DecoderCaptured(from: superDecoder(forKey: key)) 21 | } 22 | } 23 | 24 | public protocol CodableToggleProvider: ToggleProvider { 25 | associatedtype DecoderCaptured: DecoderCapturing 26 | 27 | var dataPublisher: AnyPublisher, Never> { get } 28 | } 29 | 30 | public extension CodableToggleProvider { 31 | func value(for toggle: ProviderResolvingFeatureToggle) throws -> T { 32 | try value(for: toggle).single().get() 33 | } 34 | 35 | func value(for group: ProviderResolvingFeatureGroup) throws -> T { 36 | try value(for: group.toggle) 37 | } 38 | 39 | func value(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 40 | self.decoder(for: toggle).map { decoder in 41 | decoder.flatMap { decoder in 42 | Result { 43 | try toggle.get(decoder) 44 | } 45 | } 46 | } 47 | .eraseToAnyPublisher() 48 | } 49 | 50 | func value(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 51 | value(for: group.toggle) 52 | } 53 | 54 | func decoder(for toggle: ProviderResolvingFeatureToggle) throws -> ToggleDecoder { 55 | try decoder(for: toggle).single().get() 56 | } 57 | 58 | func decoder(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 59 | dataPublisher 60 | .catch { Just>(.failure($0)) } 61 | .map { data in 62 | data.flatMap { data in 63 | Result { 64 | try CodableToggleDecoder( 65 | key: toggle.key, 66 | decoder: JSONDecoder().decode(Self.DecoderCaptured.self, from: data).decoder 67 | ) 68 | } 69 | } 70 | } 71 | .eraseToAnyPublisher() 72 | } 73 | 74 | func decoder(for group: ProviderResolvingFeatureGroup) throws -> ToggleDecoder { 75 | try decoder(for: group).single().get() 76 | } 77 | 78 | func decoder(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 79 | dataPublisher 80 | .catch { Just>(.failure($0)) } 81 | .map { _ in 82 | Result { 83 | try FeatureGroupDecoder( 84 | key: group.toggle.key, 85 | providerDecoder: self.decoder(for: group.toggle), 86 | overrideDecoder: self.override.decoder(for: group.toggle) 87 | ) 88 | } 89 | } 90 | .eraseToAnyPublisher() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/Debug/DebugToggle.swift: -------------------------------------------------------------------------------- 1 | 2 | public internal(set) var debugToggles = [DebugToggle]() 3 | 4 | public protocol DebugToggleConvertible { 5 | var debugToggle: DebugToggle { get } 6 | } 7 | 8 | public protocol FeatureGroupDebugToggleConvertible { 9 | func debugToggle( 10 | groupKey: String, 11 | userInfo: [String: Any], 12 | provider: ToggleProvider 13 | ) -> DebugToggle 14 | } 15 | 16 | /// Debug representation of a feature toggle or feature group's property used by DebugView 17 | public struct DebugToggle: Identifiable { 18 | public let type: Any.Type 19 | public let key: String 20 | public let description: String 21 | public let value: () throws -> AnyHashable 22 | public let remoteValue: (() throws -> AnyHashable)? 23 | public let debugValues: [AnyHashable] 24 | public let defaultValue: AnyHashable 25 | public let provider: ToggleProvider 26 | public let toggleType: Any.Type 27 | 28 | public var groupToggles: [DebugToggle] 29 | 30 | public var isGroup: Bool { 31 | !groupToggles.isEmpty 32 | } 33 | 34 | public mutating func override(with value: AnyHashable?) { 35 | provider.override.setValue(value, forKey: key) 36 | } 37 | 38 | public var id: String { 39 | key + "\(type)" + "\(toggleType)" 40 | } 41 | 42 | public func valueOrDefault() -> AnyHashable { 43 | var error: Error? 44 | return valueOrDefault(error: &error) 45 | } 46 | 47 | public func valueOrDefault(error outError: inout Error?) -> AnyHashable { 48 | do { 49 | return try value() 50 | } catch { 51 | outError = error 52 | return defaultValue 53 | } 54 | } 55 | } 56 | 57 | extension ProviderResolvingFeatureToggle: DebugToggleConvertible { 58 | public func debugToggle(toggleType: Any.Type) -> DebugToggle { 59 | DebugToggle( 60 | type: T.self, 61 | key: self.key, 62 | description: self.debugDescription, 63 | value: { try self.provider.effectiveValue(for: self) }, 64 | remoteValue: { try self.provider.value(for: self) }, 65 | debugValues: self.debugValues, 66 | defaultValue: self.defaultValue, 67 | provider: self.provider, 68 | toggleType: toggleType, 69 | groupToggles: [] 70 | ) 71 | } 72 | 73 | public var debugToggle: DebugToggle { 74 | debugToggle(toggleType: Self.self) 75 | } 76 | } 77 | 78 | extension ObservableFeatureToggle: DebugToggleConvertible { 79 | public var debugToggle: DebugToggle { 80 | toggle.debugToggle(toggleType: Self.self) 81 | } 82 | } 83 | 84 | extension ProviderResolvingFeatureGroup: DebugToggleConvertible { 85 | func childDebugToggle( 86 | _ child: FeatureGroupDebugToggleConvertible, 87 | groupKey: String, 88 | userInfo: [String: Any], 89 | provider: ToggleProvider 90 | ) -> DebugToggle { 91 | var debugToggle = child.debugToggle( 92 | groupKey: groupKey, 93 | userInfo: userInfo, 94 | provider: provider 95 | ) 96 | debugToggle.groupToggles = Mirror(debugToggle.defaultValue).children().map { (child: FeatureGroupDebugToggleConvertible) in 97 | childDebugToggle( 98 | child, 99 | groupKey: debugToggle.key, 100 | userInfo: userInfo, 101 | provider: provider 102 | ) 103 | } 104 | return debugToggle 105 | } 106 | 107 | func debugToggle(toggleType: Any.Type) -> DebugToggle { 108 | var debugToggle = toggle.debugToggle(toggleType: Self.self) 109 | debugToggle.groupToggles = Mirror(T()).children().map { (child: FeatureGroupDebugToggleConvertible) in 110 | childDebugToggle( 111 | child, 112 | groupKey: self.toggle.key, 113 | userInfo: self.toggle.userInfo, 114 | provider: self.toggle.provider 115 | ) 116 | } 117 | return debugToggle 118 | } 119 | 120 | public var debugToggle: DebugToggle { 121 | debugToggle(toggleType: Self.self) 122 | } 123 | } 124 | 125 | extension ObservableFeatureGroup: DebugToggleConvertible { 126 | public var debugToggle: DebugToggle { 127 | group.debugToggle(toggleType: Self.self) 128 | } 129 | } 130 | 131 | extension FeatureGroupProperty: FeatureGroupDebugToggleConvertible { 132 | public func debugToggle( 133 | groupKey: String, 134 | userInfo: [String: Any], 135 | provider: ToggleProvider, 136 | toggleType: Any.Type 137 | ) -> DebugToggle { 138 | let toggle = FeatureToggle( 139 | key: [groupKey, self.key].joined(), 140 | defaultValue: defaultValue, 141 | userInfo: userInfo, 142 | debugValues: debugValues, 143 | debugDescription: debugDescription, 144 | get: { decoder in 145 | var decoder = decoder 146 | decoder.key = groupKey 147 | return try self.get(decoder) 148 | }, 149 | provider: provider 150 | ) 151 | return toggle.debugToggle(toggleType: toggleType) 152 | } 153 | 154 | public func debugToggle( 155 | groupKey: String, 156 | userInfo: [String: Any], 157 | provider: ToggleProvider 158 | ) -> DebugToggle { 159 | debugToggle( 160 | groupKey: groupKey, 161 | userInfo: userInfo, 162 | provider: provider, 163 | toggleType: Self.self 164 | ) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/Debug/DebugView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | extension DebugToggle { 5 | var isBoolean: Bool { 6 | type == Bool.self || type == Optional.self 7 | } 8 | 9 | var isString: Bool { 10 | type == String.self || type == Optional.self 11 | } 12 | 13 | var typeDescription: String { 14 | let typeDescription = String(describing: type) 15 | return typeDescription.hasPrefix("Optional<") 16 | ? String(typeDescription.dropFirst("Optional<".count).dropLast()) 17 | : typeDescription 18 | } 19 | 20 | func isSelected(debugValue: AnyHashable, selectedValue: AnyHashable) -> Bool { 21 | selectedValue.hashValue == debugValue.hashValue 22 | || selectedValue.hashValue == Optional.some(debugValue).hashValue 23 | } 24 | } 25 | 26 | extension View { 27 | 28 | func infoAlert(title: String, message: String) { 29 | let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) 30 | 31 | let cancelAction = UIAlertAction(title: "OK", style: .default, handler: { _ in }) 32 | alertController.addAction(cancelAction) 33 | 34 | var root = UIApplication.shared.windows.first?.rootViewController 35 | while root?.presentedViewController != nil { 36 | root = root?.presentedViewController 37 | } 38 | root?.present(alertController, animated: true, completion: nil) 39 | } 40 | 41 | func inputAlert(value: AnyHashable, completion: @escaping (String?) -> Void) { 42 | let alertController = UIAlertController(title: "Enter new value", message: nil, preferredStyle: .alert) 43 | 44 | alertController.addTextField { (textField : UITextField!) -> Void in 45 | textField.placeholder = String(describing: value) 46 | } 47 | 48 | let saveAction = UIAlertAction(title: "Save", style: .default, handler: { alert -> Void in 49 | let value = alertController.textFields?.first?.text 50 | completion(value) 51 | }) 52 | 53 | let cancelAction = UIAlertAction(title: "Cancel", style: .default, handler: { _ in completion(nil) }) 54 | 55 | 56 | alertController.addAction(saveAction) 57 | alertController.addAction(cancelAction) 58 | 59 | var root = UIApplication.shared.windows.first?.rootViewController 60 | while root?.presentedViewController != nil { 61 | root = root?.presentedViewController 62 | } 63 | root?.present(alertController, animated: true, completion: nil) 64 | } 65 | } 66 | 67 | class DebugViewState: ObservableObject { 68 | @Published var toggles: [DebugToggle] = ToggleUI.debugToggles 69 | @Published var providers: Set = [] 70 | } 71 | 72 | /// A view that displays and allows to override feature toggles at the application runtime. 73 | public struct DebugView: View { 74 | @ObservedObject var state = DebugViewState() 75 | 76 | public init() {} 77 | 78 | var filters: some View { 79 | let items = state.toggles.map { $0.provider.name } 80 | let texts = NSOrderedSet(array: items).map { 81 | String(describing: $0) 82 | } 83 | return VStack { 84 | VStack(alignment: .leading) { 85 | VStack(alignment: .leading) { 86 | Text("Providers:").bold().font(.callout) 87 | ScrollView(.horizontal) { 88 | HStack { 89 | ForEach(texts.indices) { index -> Tag in 90 | let text = texts[index] 91 | return Tag(text, color: self.state.providers.contains(text) 92 | ? Color.green 93 | : Color.gray 94 | ) { 95 | if self.state.providers.contains(text) { 96 | self.state.providers.remove(text) 97 | } else { 98 | self.state.providers.insert(text) 99 | } 100 | } 101 | } 102 | }.padding(2) 103 | } 104 | } 105 | }.padding([.horizontal, .top]) 106 | Divider() 107 | }.padding(.bottom, -8) 108 | } 109 | 110 | public var body: some View { 111 | NavigationView { 112 | VStack { 113 | filters 114 | ToggleList(toggles: $state.toggles) { 115 | self.state.providers.contains($0.provider.name) || self.state.providers.isEmpty 116 | } 117 | } 118 | .navigationBarTitle("Feature Toggles", displayMode: .inline) 119 | .navigationBarItems(trailing: ResetAll(toggles: self.$state.toggles)) 120 | } 121 | } 122 | } 123 | 124 | struct ResetAll: View { 125 | @Binding var toggles: [DebugToggle] 126 | 127 | var body: some View { 128 | Button("Reset all", action: { 129 | self.toggles = self.toggles.map { (toggle) in 130 | var toggle = toggle 131 | toggle.override(with: nil) 132 | return toggle 133 | } 134 | }).disabled(!toggles.contains(where: { toggle in 135 | toggle.provider.override.hasValue(for: toggle.key) 136 | })) 137 | } 138 | } 139 | 140 | struct ToggleList: View { 141 | @Binding var toggles: [DebugToggle] 142 | let filter: (DebugToggle) -> Bool 143 | 144 | init(toggles: Binding<[DebugToggle]>, filter: @escaping (DebugToggle) -> Bool = { _ in true }) { 145 | self._toggles = toggles 146 | self.filter = filter 147 | } 148 | 149 | var body: some View { 150 | List { 151 | ForEach(toggles.filter(filter)) { toggle in 152 | ToggleCell(toggle: Binding(get: { 153 | self.toggles.first(where: { $0.id == toggle.id })! 154 | }, set: { toggle in 155 | let index = self.toggles.firstIndex(where: { $0.id == toggle.id })! 156 | self.toggles[index] = toggle 157 | })) 158 | } 159 | } 160 | } 161 | } 162 | 163 | extension View { 164 | func push(isActive: Binding, @ViewBuilder destination: () -> V) -> some View { 165 | background( 166 | NavigationLink( 167 | destination: destination(), 168 | isActive: isActive, 169 | label: { EmptyView() } 170 | ) 171 | ) 172 | } 173 | } 174 | 175 | struct ToggleCell: View { 176 | @Binding var toggle: DebugToggle 177 | 178 | @ViewBuilder 179 | var body: some View { 180 | if toggle.isGroup { 181 | NavigationLink(destination: GroupView(toggle: toggle)) { 182 | content 183 | } 184 | } 185 | else { 186 | content 187 | } 188 | } 189 | 190 | var content: some View { 191 | HStack(alignment: .center) { 192 | ToggleDescription(toggle: $toggle) 193 | Spacer() 194 | if toggle.isBoolean { 195 | BooleanToggle(toggle: $toggle) 196 | } else if toggle.isString { 197 | StringToggle(toggle: $toggle) 198 | } else if !toggle.debugValues.isEmpty { 199 | EnumToggle(toggle: $toggle) 200 | } else { 201 | Text(toggle.typeDescription).lineLimit(1) 202 | } 203 | } 204 | } 205 | } 206 | 207 | struct ToggleDescription: View { 208 | @Binding var toggle: DebugToggle 209 | 210 | var body: some View { 211 | var error: Error? 212 | _ = toggle.valueOrDefault(error: &error) 213 | 214 | return VStack(alignment: .leading, spacing: 8) { 215 | Text("Key: ").bold() + Text(toggle.key) 216 | if !toggle.description.isEmpty { 217 | Text(toggle.description) 218 | } 219 | HStack { 220 | Tag(toggle.provider.name, color: Color.green) 221 | if toggle.provider.override.hasValue(for: toggle.key) { 222 | Tag("Overriden", color: Color.orange) { 223 | self.toggle.override(with: nil) 224 | } 225 | } 226 | if error != nil { 227 | Tag("⚠️ Error", color: Color.red) { 228 | self.infoAlert(title: "Error", message: error!.localizedDescription) 229 | } 230 | } 231 | } 232 | }.fixedSize(horizontal: true, vertical: true) 233 | } 234 | } 235 | 236 | struct Tag: View { 237 | let text: String 238 | let color: Color? 239 | let action: (() -> Void)? 240 | 241 | init(_ text: String, color: Color?, action: (() -> Void)? = nil) { 242 | self.text = text 243 | self.color = color 244 | self.action = action 245 | } 246 | var body: some View { 247 | Text(text) 248 | .font(.footnote) 249 | .padding(4) 250 | .background(color ?? .white) 251 | .cornerRadius(4) 252 | .overlay(RoundedRectangle(cornerRadius: 4) 253 | .stroke(Color.black, lineWidth: 1)) 254 | .onTapGesture { 255 | self.action?() 256 | } 257 | } 258 | } 259 | 260 | struct BooleanToggle: View { 261 | @Binding var toggle: DebugToggle 262 | 263 | var body: some View { 264 | Toggle( 265 | "", 266 | isOn: Binding( 267 | get: { self.toggle.valueOrDefault() as? Bool == true }, 268 | set: { newValue in self.toggle.override(with: newValue) } 269 | ) 270 | ).fixedSize(horizontal: true, vertical: false) 271 | } 272 | } 273 | 274 | struct StringToggle: View { 275 | @Binding var toggle: DebugToggle 276 | 277 | var body: some View { 278 | let value = toggle.valueOrDefault() 279 | 280 | return Button(action: { 281 | self.inputAlert(value: value) { newValue in 282 | if let newValue = newValue, !newValue.isEmpty { 283 | self.toggle.override(with: newValue) 284 | } 285 | } 286 | }) { 287 | Text(String(describing: value)) 288 | } 289 | } 290 | } 291 | 292 | struct EnumToggle: View { 293 | @Binding var toggle: DebugToggle 294 | @State var showsActionSheet: Bool = false 295 | 296 | var body: some View { 297 | let value = toggle.valueOrDefault() 298 | let selectedValue = toggle.debugValues.first { 299 | toggle.isSelected(debugValue: $0, selectedValue: value) 300 | } ?? toggle.type as Any 301 | 302 | return Button(String(describing: selectedValue)) { 303 | self.showsActionSheet = true 304 | } 305 | .actionSheet(isPresented: $showsActionSheet) { 306 | ActionSheet(title: Text("Select a new value"), message: nil, buttons: 307 | toggle.debugValues.map { debugValue -> ActionSheet.Button in 308 | self.toggle.isSelected(debugValue: debugValue, selectedValue: value) 309 | ? .destructive(Text(verbatim: "\(debugValue)")) 310 | : .default(Text(verbatim: "\(debugValue)")) 311 | } + [.cancel()] 312 | ) 313 | } 314 | } 315 | } 316 | 317 | // Separate state for group views as bindings seem to break updates on nested views 318 | class GroupViewState: ObservableObject { 319 | @Published var toggle: DebugToggle 320 | 321 | init(toggle: DebugToggle) { 322 | self.toggle = toggle 323 | } 324 | } 325 | 326 | struct GroupView: View { 327 | @ObservedObject var state: GroupViewState 328 | 329 | init(toggle: DebugToggle) { 330 | self.state = GroupViewState(toggle: toggle) 331 | } 332 | 333 | var header: some View { 334 | VStack(alignment: .leading) { 335 | VStack(alignment: .leading, spacing: 8) { 336 | Text("Type: ").bold() + Text(String(describing: state.toggle.typeDescription)) 337 | Text(state.toggle.description) 338 | Spacer() 339 | }.padding([.horizontal, .top]) 340 | Divider() 341 | } 342 | .padding(.bottom, -8) 343 | .fixedSize(horizontal: false, vertical: true) 344 | } 345 | 346 | var body: some View { 347 | VStack { 348 | header 349 | ToggleList(toggles: $state.toggle.groupToggles) 350 | } 351 | .navigationBarTitle("Feature Group", displayMode: .inline) 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureGroup/AnyFeatureGroup.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | @dynamicMemberLookup 5 | public class AnyFeatureGroup: ObservableObject { 6 | @Published public private(set) var wrappedValue: T 7 | private var getWrappedValue: (() -> T)? 8 | private var cancellable: AnyCancellable? 9 | 10 | public init

(_ group: ProviderResolvingObservableFeatureGroup) { 11 | self.wrappedValue = group.group.toggle.defaultValue 12 | self.cancellable = group.wrappedValue.sink { [weak self] value in 13 | self?.wrappedValue = value 14 | } 15 | } 16 | 17 | public init

(_ group: ProviderResolvingFeatureGroup) { 18 | self.wrappedValue = group.wrappedValue 19 | self.getWrappedValue = { group.wrappedValue } 20 | } 21 | 22 | public subscript(dynamicMember keyPath: KeyPath>) -> Binding { 23 | return Binding( 24 | get: { (self.getWrappedValue?() ?? self.wrappedValue)[keyPath: keyPath].wrappedValue }, 25 | set: { _ in } 26 | ) 27 | } 28 | } 29 | 30 | @dynamicMemberLookup 31 | public class AnyMutableFeatureGroup: ObservableObject { 32 | @Published public private(set) var wrappedValue: T 33 | private var getWrappedValue: (() -> T)? 34 | private var update: (T, Any, String) -> Void 35 | private var cancellable: AnyCancellable? 36 | 37 | public init

(_ group: ProviderResolvingObservableFeatureGroup) { 38 | self.wrappedValue = group.group.toggle.defaultValue 39 | self.update = { _, _, _ in } 40 | self.cancellable = group.wrappedValue.sink { [weak self] value in 41 | guard self?.wrappedValue != value else { return } 42 | self?.wrappedValue = value 43 | } 44 | self.update = { [weak self] newWrappedValueValue, newPropertyValue, propertyKey in 45 | self?.wrappedValue = newWrappedValueValue 46 | group.group.toggle.override.setValue(newPropertyValue, forKey: [group.group.toggle.key, propertyKey].joined()) 47 | } 48 | } 49 | 50 | public init

(_ group: ProviderResolvingFeatureGroup) { 51 | self.wrappedValue = group.wrappedValue 52 | self.getWrappedValue = { group.wrappedValue } 53 | self.update = { _, _, _ in } 54 | self.update = { [weak self] newWrappedValueValue, newPropertyValue, propertyKey in 55 | self?.wrappedValue = newWrappedValueValue 56 | group.toggle.override.setValue(newPropertyValue, forKey: [group.toggle.key, propertyKey].joined()) 57 | } 58 | } 59 | 60 | public subscript(dynamicMember keyPath: KeyPath>) -> Binding { 61 | return Binding( 62 | get: { (self.getWrappedValue?() ?? self.wrappedValue)[keyPath: keyPath].wrappedValue }, 63 | set: { newValue in 64 | self.wrappedValue[keyPath: keyPath].update(newValue) 65 | self.update(self.wrappedValue, newValue, self.wrappedValue[keyPath: keyPath].key) 66 | } 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureGroup/FeatureGroup.swift: -------------------------------------------------------------------------------- 1 | /// Feature group property wrapper that accepts toggle provider as constructor parameter 2 | public typealias FeatureGroup = ProviderResolvingFeatureGroup 3 | 4 | /// Feature group is a collection of related feature toggles defined using `FeatureGroupProperty` property wrapper. 5 | @propertyWrapper @dynamicMemberLookup 6 | public struct ProviderResolvingFeatureGroup { 7 | public var toggle: ProviderResolvingFeatureToggle 8 | 9 | /// Effective value of the feature group or its default value if computing effective value throws an error 10 | public var wrappedValue: T { 11 | (try? toggle.provider.effectiveValue(for: self)) 12 | ?? toggle.defaultValue 13 | } 14 | 15 | /// Feature group itself 16 | /// - Note: Setter only updates default value of the backing feature toggle, should be only used in `WithDefaults` implementation 17 | public var projectedValue: Self { 18 | get { self } 19 | set { self.toggle.defaultValue = newValue.toggle.defaultValue } 20 | } 21 | 22 | public init(toggle: ProviderResolvingFeatureToggle) { 23 | self.toggle = toggle 24 | self.defaultValue = DefaultValueProxy(wrappedValue: toggle.defaultValue) 25 | } 26 | 27 | /// Proxy to set default value of feature group's properties 28 | /// 29 | /// Example: 30 | /// ``` 31 | /// myFeatures.$myFeatureGroup.defaultValue.$featureProperty = 32 | /// ``` 33 | public var defaultValue: DefaultValueProxy { 34 | willSet { toggle.defaultValue = newValue.wrappedValue } 35 | } 36 | 37 | @dynamicMemberLookup 38 | public struct DefaultValueProxy { 39 | var wrappedValue: T 40 | 41 | public subscript(dynamicMember keyPath: WritableKeyPath>) -> U { 42 | get { wrappedValue[keyPath: keyPath].defaultValue } 43 | set { wrappedValue[keyPath: keyPath].defaultValue = newValue } 44 | } 45 | } 46 | 47 | public subscript(dynamicMember keyPath: KeyPath, U>) -> U { 48 | toggle[keyPath: keyPath] 49 | } 50 | 51 | @dynamicMemberLookup 52 | public struct PropertyProxy { 53 | var property: FeatureGroupProperty 54 | 55 | /// Overrides value for the feature property key by setting it with `override` provider, should be only used in `WithDefaults` implementation 56 | /// 57 | /// Example: 58 | /// ``` 59 | /// myFeatures.$myFeatureGroup.$featureProperty.setValue() 60 | /// ``` 61 | public mutating func setValue(_ value: T) { 62 | property.wrappedValue = value 63 | } 64 | 65 | public subscript(dynamicMember keyPath: KeyPath, U>) -> U { 66 | property[keyPath: keyPath] 67 | } 68 | } 69 | 70 | public subscript(dynamicMember keyPath: KeyPath>) -> PropertyProxy { 71 | get { 72 | PropertyProxy(property: wrappedValue[keyPath: keyPath]) 73 | } 74 | mutating set { 75 | toggle.override.setValue( 76 | newValue.wrappedValue, 77 | forKey: [toggle.key, wrappedValue[keyPath: keyPath].key].joined() 78 | ) 79 | } 80 | } 81 | } 82 | 83 | extension ProviderResolvingFeatureGroup where P == EmptyToggleProviderResolving { 84 | public init( 85 | key: String, 86 | debugDescription: String = "", 87 | provider: ToggleProvider 88 | ) { 89 | self.init(toggle: FeatureToggle( 90 | key: key, 91 | defaultValue: T(), 92 | debugValues: [], 93 | debugDescription: debugDescription, 94 | get: { try T(decoder: $0) }, 95 | provider: provider 96 | )) 97 | } 98 | 99 | public init( 100 | key: Key, 101 | debugDescription: String = "", 102 | provider: ToggleProvider 103 | ) where Key.RawValue == String { 104 | self.init( 105 | key: key.rawValue, 106 | debugDescription: debugDescription, 107 | provider: provider 108 | ) 109 | } 110 | } 111 | 112 | extension ProviderResolvingFeatureGroup { 113 | public init( 114 | key: String, 115 | debugDescription: String = "" 116 | ) { 117 | self.init(toggle: ProviderResolvingFeatureToggle( 118 | key: key, 119 | defaultValue: T(), 120 | debugValues: [], 121 | debugDescription: debugDescription, 122 | get: { try T(decoder: $0) } 123 | )) 124 | } 125 | 126 | public init( 127 | key: Key, 128 | debugDescription: String = "", 129 | provider: ToggleProvider 130 | ) where Key.RawValue == String { 131 | self.init( 132 | key: key.rawValue, 133 | debugDescription: debugDescription 134 | ) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureGroup/FeatureGroupDecoder.swift: -------------------------------------------------------------------------------- 1 | public protocol FeatureGroupDecodable: Hashable { 2 | init() 3 | init(decoder: ToggleDecoder) throws 4 | } 5 | 6 | public extension FeatureGroupDecodable { 7 | init(decoder: ToggleDecoder) throws { 8 | self = Self() 9 | 10 | Mirror(self).children().forEach { (toggle: FeatureGroupPropertyType) in 11 | toggle.decode(from: decoder) 12 | } 13 | } 14 | } 15 | 16 | extension Mirror { 17 | init(_ value: Any) { 18 | var mirror = Mirror(reflecting: value) 19 | if let hashable = value as? AnyHashable { 20 | mirror = Mirror(reflecting: hashable.base) 21 | } 22 | if case .optional = mirror.displayStyle, let optional = mirror.children.first { 23 | mirror = Mirror(reflecting: optional.value) 24 | } 25 | self = mirror 26 | } 27 | 28 | func children(of type: T.Type = T.self) -> [T] { 29 | children.compactMap { 30 | $0.value as? T 31 | } 32 | } 33 | } 34 | 35 | /// Decoder that can decode feature group properties. First it tries to decode value from override decoder and then falls back to provider decoder. 36 | /// This is so that when some properties of a feature group are overridden the decode can still decode other properties of the group using provider decoder 37 | public struct FeatureGroupDecoder: ToggleDecoder { 38 | var providerDecoder: ToggleDecoder 39 | var overrideDecoder: ToggleDecoder 40 | 41 | public init( 42 | key: String, 43 | providerDecoder: ToggleDecoder, 44 | overrideDecoder: ToggleDecoder 45 | ) { 46 | self.key = key 47 | self.providerDecoder = providerDecoder 48 | self.overrideDecoder = overrideDecoder 49 | } 50 | 51 | public var key: String { 52 | didSet { 53 | providerDecoder.key = key 54 | overrideDecoder.key = key 55 | } 56 | } 57 | 58 | public func decode() throws -> Bool { 59 | do { 60 | return try overrideDecoder.decode() 61 | } catch { 62 | return try providerDecoder.decode() 63 | } 64 | } 65 | 66 | public func decode() throws -> String { 67 | do { 68 | return try overrideDecoder.decode() 69 | } catch { 70 | return try providerDecoder.decode() 71 | } 72 | } 73 | 74 | public func decode(key: String) throws -> Bool { 75 | do { 76 | return try overrideDecoder.decode(key: key) 77 | } catch { 78 | return try providerDecoder.decode(key: key) 79 | } 80 | } 81 | 82 | public func decode(key: String) throws -> String { 83 | do { 84 | return try overrideDecoder.decode(key: key) 85 | } catch { 86 | return try providerDecoder.decode(key: key) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureGroup/FeatureGroupProperty.swift: -------------------------------------------------------------------------------- 1 | @propertyWrapper 2 | public class Reference { 3 | public internal(set) var wrappedValue: T 4 | init(_ wrappedValue: T) { 5 | self.wrappedValue = wrappedValue 6 | } 7 | } 8 | 9 | protocol FeatureGroupPropertyType { 10 | func decode(from decoder: ToggleDecoder) 11 | } 12 | 13 | /// Feature group property defines a single property of a feature group that can be treated as a standalone feature toggle. 14 | /// Feature group property uses provider of its feature group. Feature group property key is a key path which first component is a feature group key. 15 | @propertyWrapper 16 | public struct FeatureGroupProperty: FeatureGroupPropertyType, Hashable { 17 | public let key: String 18 | public var defaultValue: T 19 | public let userInfo: [String: AnyHashable] 20 | public let get: FeatureToggle.Getter 21 | 22 | public let debugValues: [AnyHashable] 23 | public let debugDescription: String 24 | 25 | @Reference public internal(set) var wrappedValue: T 26 | 27 | /// Feature group property itself 28 | /// - Note: Setter only updates default value of the feature toggle, should be only used in `WithDefaults` implementation 29 | public var projectedValue: Self { 30 | get { self } 31 | set { 32 | self.defaultValue = newValue.defaultValue 33 | // projected value supposed to be changed only during init 34 | // this way we keep initial value consisten with default value 35 | // set in init or initWithDefaults 36 | self.wrappedValue = defaultValue 37 | } 38 | } 39 | 40 | public init( 41 | key: String, 42 | defaultValue: T, 43 | userInfo: [String: AnyHashable] = [:], 44 | debugValues: [AnyHashable] = [], 45 | debugDescription: String = "", 46 | get: @escaping FeatureToggle.Getter 47 | ) { 48 | self.key = key 49 | self.defaultValue = defaultValue 50 | self.userInfo = userInfo 51 | self.debugValues = debugValues 52 | self.debugDescription = debugDescription 53 | self.get = get 54 | self._wrappedValue = Reference(defaultValue) 55 | } 56 | 57 | public init( 58 | key: Key, 59 | defaultValue: T, 60 | userInfo: [String: AnyHashable] = [:], 61 | debugValues: [AnyHashable] = [], 62 | debugDescription: String = "", 63 | get: @escaping FeatureToggle.Getter 64 | ) where Key.RawValue == String { 65 | self.init( 66 | key: key.rawValue, 67 | defaultValue: defaultValue, 68 | userInfo: userInfo, 69 | debugValues: debugValues, 70 | debugDescription: debugDescription, 71 | get: get 72 | ) 73 | } 74 | 75 | func decode(from decoder: ToggleDecoder) { 76 | wrappedValue = (try? get(decoder)) ?? wrappedValue 77 | } 78 | 79 | // Update value through binding 80 | func update(_ value: T) { 81 | wrappedValue = value 82 | } 83 | 84 | public func hash(into hasher: inout Hasher) { 85 | hasher.combine(key) 86 | hasher.combine(wrappedValue) 87 | hasher.combine(defaultValue) 88 | hasher.combine(userInfo) 89 | hasher.combine(debugValues) 90 | hasher.combine(debugDescription) 91 | } 92 | 93 | public static func ==(lhs: Self, rhs: Self) -> Bool { 94 | guard lhs.key == rhs.key else { return false } 95 | guard lhs.wrappedValue == rhs.wrappedValue else { return false } 96 | guard lhs.defaultValue == rhs.defaultValue else { return false } 97 | guard lhs.userInfo == rhs.userInfo else { return false } 98 | guard lhs.debugValues == rhs.debugValues else { return false } 99 | guard lhs.debugDescription == rhs.debugDescription else { return false } 100 | return true 101 | } 102 | } 103 | 104 | public extension FeatureGroupProperty where T: ExpressibleByBooleanLiteral, T.BooleanLiteralType == Bool { 105 | init( 106 | key: String, 107 | defaultValue: T = false, 108 | userInfo: [String: AnyHashable] = [:], 109 | debugDescription: String = "" 110 | ) { 111 | self.init( 112 | key: key, 113 | defaultValue: defaultValue, 114 | userInfo: userInfo, 115 | debugValues: [], 116 | debugDescription: debugDescription, 117 | get: { try T(booleanLiteral: $0.decode(key: key)) } 118 | ) 119 | } 120 | 121 | init( 122 | key: Key, 123 | defaultValue: T = false, 124 | userInfo: [String: AnyHashable] = [:], 125 | debugDescription: String = "" 126 | ) where Key.RawValue == String { 127 | self.init( 128 | key: key.rawValue, 129 | defaultValue: defaultValue, 130 | userInfo: userInfo, 131 | debugDescription: debugDescription 132 | ) 133 | } 134 | } 135 | 136 | public extension FeatureGroupProperty where T: ExpressibleByStringLiteral, T.StringLiteralType == String { 137 | init( 138 | key: String, 139 | defaultValue: T = "", 140 | userInfo: [String: AnyHashable] = [:], 141 | debugValues: [T] = [], 142 | debugDescription: String = "" 143 | ) { 144 | self.init( 145 | key: key, 146 | defaultValue: defaultValue, 147 | userInfo: userInfo, 148 | debugValues: debugValues, 149 | debugDescription: debugDescription, 150 | get: { try T(stringLiteral: $0.decode(key: key)) } 151 | ) 152 | } 153 | 154 | init( 155 | key: Key, 156 | defaultValue: T = "", 157 | userInfo: [String: AnyHashable] = [:], 158 | debugValues: [T] = [], 159 | debugDescription: String = "" 160 | ) where Key.RawValue == String { 161 | self.init( 162 | key: key.rawValue, 163 | defaultValue: defaultValue, 164 | userInfo: userInfo, 165 | debugValues: debugValues, 166 | debugDescription: debugDescription 167 | ) 168 | } 169 | } 170 | 171 | extension FeatureGroupProperty where T: RawRepresentable & CaseIterable, T.RawValue == String { 172 | public init( 173 | key: String, 174 | defaultValue: T, 175 | userInfo: [String: AnyHashable] = [:], 176 | debugDescription: String = "" 177 | ) { 178 | self.init( 179 | key: key, 180 | defaultValue: defaultValue, 181 | userInfo: userInfo, 182 | debugValues: Array(T.allCases).map { $0.rawValue }, 183 | debugDescription: debugDescription, 184 | get: { try T(rawValue: $0.decode(key: key)) ?? defaultValue } 185 | ) 186 | } 187 | 188 | public init( 189 | key: Key, 190 | defaultValue: T, 191 | userInfo: [String: AnyHashable] = [:], 192 | debugDescription: String = "" 193 | ) where Key.RawValue == String { 194 | self.init( 195 | key: key.rawValue, 196 | defaultValue: defaultValue, 197 | userInfo: userInfo, 198 | debugDescription: debugDescription 199 | ) 200 | } 201 | } 202 | 203 | extension FeatureGroupProperty where T: FeatureGroupDecodable { 204 | public init( 205 | key: String, 206 | userInfo: [String: AnyHashable] = [:], 207 | debugValues: [AnyHashable] = [], 208 | debugDescription: String = "" 209 | ) { 210 | self.init( 211 | key: key, 212 | defaultValue: T(), 213 | userInfo: userInfo, 214 | debugValues: debugValues, 215 | debugDescription: debugDescription, 216 | get: { decoder in 217 | var decoder = decoder 218 | decoder.key = [decoder.key, key].joined() 219 | return try T(decoder: decoder) 220 | } 221 | ) 222 | } 223 | 224 | public init( 225 | key: Key, 226 | userInfo: [String: AnyHashable] = [:], 227 | debugValues: [AnyHashable] = [], 228 | debugDescription: String = "" 229 | ) where Key.RawValue == String { 230 | self.init( 231 | key: key.rawValue, 232 | userInfo: userInfo, 233 | debugValues: debugValues, 234 | debugDescription: debugDescription 235 | ) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureGroup/ObservableFeatureGroup.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// Feature group property wrapper that accepts toggle provider as constructor parameter 5 | public typealias ObservableFeatureGroup = ProviderResolvingObservableFeatureGroup 6 | 7 | /// Feature group variant that provides observable value 8 | @propertyWrapper @dynamicMemberLookup 9 | public struct ProviderResolvingObservableFeatureGroup { 10 | public var group: ProviderResolvingFeatureGroup 11 | 12 | /// Publisher that publishes effective value of the feature group or its default value if computing effective value throws an error 13 | public let wrappedValue: AnyPublisher 14 | 15 | /// Feature group itself 16 | /// - Note: Setter only updates default value of the backing feature toggle, should be only used in `WithDefaults` implementation 17 | public var projectedValue: Self { 18 | get { self } 19 | set { self.group.toggle.defaultValue = newValue.group.toggle.defaultValue } 20 | } 21 | 22 | public init(group: ProviderResolvingFeatureGroup) { 23 | self.group = group 24 | self.wrappedValue = group.toggle.provider.effectiveValue(for: group) 25 | .map { (try? $0.get()) ?? group.toggle.defaultValue } 26 | .receive(on: DispatchQueue.main) 27 | .removeDuplicates() 28 | .eraseToAnyPublisher() 29 | self.defaultValue = DefaultValueProxy(wrappedValue: group.toggle.defaultValue) 30 | } 31 | 32 | /// Proxy to set default value of feature group's properties 33 | /// 34 | /// Example: 35 | /// ``` 36 | /// myFeatures.$myFeatureGroup.defaultValue.$featureProperty = 37 | /// ``` 38 | public var defaultValue: DefaultValueProxy { 39 | willSet { group.toggle.defaultValue = newValue.wrappedValue } 40 | } 41 | 42 | @dynamicMemberLookup 43 | public struct DefaultValueProxy { 44 | var wrappedValue: T 45 | 46 | public subscript(dynamicMember keyPath: WritableKeyPath>) -> U { 47 | get { wrappedValue[keyPath: keyPath].defaultValue } 48 | set { wrappedValue[keyPath: keyPath].defaultValue = newValue } 49 | } 50 | } 51 | 52 | public subscript(dynamicMember keyPath: KeyPath, U>) -> U { 53 | group.toggle[keyPath: keyPath] 54 | } 55 | 56 | @dynamicMemberLookup 57 | public struct PropertyProxy { 58 | var property: FeatureGroupProperty 59 | 60 | /// Overrides value for the feature property key by setting it with `override` provider, should be only used in `WithDefaults` implementation 61 | /// 62 | /// Example: 63 | /// ``` 64 | /// myFeatures.$myFeatureGroup.$featureProperty.setValue() 65 | /// ``` 66 | public mutating func setValue(_ value: T) { 67 | property.wrappedValue = value 68 | } 69 | 70 | public subscript(dynamicMember keyPath: KeyPath, U>) -> U { 71 | property[keyPath: keyPath] 72 | } 73 | } 74 | 75 | public subscript(dynamicMember keyPath: KeyPath>) -> PropertyProxy { 76 | get { 77 | PropertyProxy(property: group.wrappedValue[keyPath: keyPath]) 78 | } 79 | mutating set { 80 | group.override.setValue( 81 | newValue.wrappedValue, 82 | forKey: [group.key, group.wrappedValue[keyPath: keyPath].key].joined() 83 | ) 84 | } 85 | } 86 | } 87 | 88 | extension ProviderResolvingObservableFeatureGroup where P == EmptyToggleProviderResolving { 89 | public init( 90 | key: String, 91 | debugDescription: String = "", 92 | provider: ToggleProvider 93 | ) { 94 | self.init(group: FeatureGroup( 95 | key: key, 96 | debugDescription: debugDescription, 97 | provider: provider 98 | )) 99 | } 100 | 101 | public init( 102 | key: Key, 103 | debugDescription: String = "", 104 | provider: ToggleProvider 105 | ) where Key.RawValue == String { 106 | self.init( 107 | key: key.rawValue, 108 | debugDescription: debugDescription, 109 | provider: provider 110 | ) 111 | } 112 | } 113 | 114 | extension ProviderResolvingObservableFeatureGroup { 115 | public init( 116 | key: String, 117 | debugDescription: String = "" 118 | ) { 119 | self.init(group: ProviderResolvingFeatureGroup( 120 | key: key, 121 | debugDescription: debugDescription 122 | )) 123 | } 124 | 125 | public init( 126 | key: Key, 127 | debugDescription: String = "" 128 | ) where Key.RawValue == String { 129 | self.init( 130 | key: key.rawValue, 131 | debugDescription: debugDescription 132 | ) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureToggle/AnyFeatureToggle.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | public class AnyFeatureToggle: ObservableObject { 5 | @Published public private(set) var wrappedValue: T 6 | private var getWrappedValue: (() -> T)? 7 | private var cancellable: AnyCancellable? 8 | 9 | public init

(_ toggle: ProviderResolvingObservableFeatureToggle) { 10 | self.wrappedValue = toggle.projectedValue.toggle.defaultValue 11 | self.cancellable = toggle.wrappedValue.sink { [weak self] value in 12 | self?.wrappedValue = value 13 | } 14 | } 15 | 16 | public init

(_ toggle: ProviderResolvingFeatureToggle) { 17 | self.wrappedValue = toggle.wrappedValue 18 | self.getWrappedValue = { toggle.wrappedValue } 19 | } 20 | 21 | public var binding: Binding { 22 | Binding(get: getWrappedValue ?? { self.wrappedValue }, set: { _ in }) 23 | } 24 | } 25 | 26 | public class AnyMutableFeatureToggle: ObservableObject { 27 | @Published public private(set) var wrappedValue: T 28 | private var getWrappedValue: (() -> T)? 29 | private var update: (T) -> Void 30 | private var cancellable: AnyCancellable? 31 | 32 | public init

(_ toggle: ProviderResolvingObservableFeatureToggle) { 33 | self.wrappedValue = toggle.projectedValue.toggle.defaultValue 34 | self.update = { _ in } 35 | self.update = { [weak self] in 36 | self?.wrappedValue = $0 37 | toggle.toggle.override.setValue($0, forKey: toggle.toggle.key) 38 | } 39 | self.cancellable = toggle.wrappedValue.sink { [weak self] value in 40 | guard self?.wrappedValue != value else { return } 41 | self?.wrappedValue = value 42 | } 43 | } 44 | 45 | public init

(_ toggle: ProviderResolvingFeatureToggle) { 46 | self.wrappedValue = toggle.wrappedValue 47 | self.getWrappedValue = { toggle.wrappedValue } 48 | self.update = { _ in } 49 | self.update = { [weak self] in 50 | self?.wrappedValue = $0 51 | toggle.override.setValue($0, forKey: toggle.key) 52 | } 53 | } 54 | 55 | public var binding: Binding { 56 | Binding(get: getWrappedValue ?? { self.wrappedValue }, set: update) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureToggle/FeatureToggle.swift: -------------------------------------------------------------------------------- 1 | /// Feature toggle property wrapper. Accepts toggle provider as constructor parameter 2 | public typealias FeatureToggle = ProviderResolvingFeatureToggle 3 | 4 | /// Feature toggle property wrapper. Accepts toggle provider as a generic type parameter 5 | @propertyWrapper 6 | public struct ProviderResolvingFeatureToggle { 7 | public let key: String 8 | public var defaultValue: T 9 | public let userInfo: [String: Any] 10 | 11 | /// Effective toggle value provider 12 | public let provider: ToggleProvider 13 | /// Shorthand for `provider.override` 14 | public var override: ToggleProvider & ToggleOverriding { provider.override } 15 | 16 | public typealias Getter = (_ decoder: ToggleDecoder) throws -> T 17 | public let get: Getter 18 | 19 | /// Predefined toggle values for debug purposes that can be selected in DebugView 20 | public let debugValues: [AnyHashable] 21 | /// Toggle description for display in DebugView 22 | public let debugDescription: String 23 | 24 | /// Effective value of the feature toggle or its default value if computing effective value throws an error 25 | public var wrappedValue: T { 26 | (try? provider.effectiveValue(for: self)) 27 | ?? defaultValue 28 | } 29 | 30 | /// Feature toggle itself 31 | /// - Note: Setter only updates default value of the feature toggle, should be only used in `WithDefaults` implementation 32 | public var projectedValue: Self { 33 | get { self } 34 | set { self.defaultValue = newValue.defaultValue } 35 | } 36 | 37 | /// Overrides value for the feature toggle key by setting it with `override` provider, should be only used in `WithDefaults` implementation 38 | public mutating func setValue(_ value: T) { 39 | override.setValue(value, forKey: key) 40 | } 41 | } 42 | 43 | extension ProviderResolvingFeatureToggle where P == EmptyToggleProviderResolving { 44 | public init( 45 | key: String, 46 | defaultValue: T, 47 | userInfo: [String: Any] = [:], 48 | debugValues: [AnyHashable] = [], 49 | debugDescription: String = "", 50 | get: @escaping Getter, 51 | provider: ToggleProvider 52 | ) { 53 | self.key = key 54 | self.defaultValue = defaultValue 55 | self.userInfo = userInfo 56 | self.debugValues = debugValues 57 | self.debugDescription = debugDescription 58 | self.get = get 59 | self.provider = provider 60 | } 61 | 62 | public init( 63 | key: Key, 64 | defaultValue: T, 65 | userInfo: [String: Any] = [:], 66 | debugValues: [AnyHashable] = [], 67 | debugDescription: String = "", 68 | get: @escaping Getter, 69 | provider: ToggleProvider 70 | ) where Key.RawValue == String { 71 | self.init( 72 | key: key.rawValue, 73 | defaultValue: defaultValue, 74 | userInfo: userInfo, 75 | debugValues: debugValues, 76 | debugDescription: debugDescription, 77 | get: get, 78 | provider: provider 79 | ) 80 | } 81 | } 82 | 83 | extension ProviderResolvingFeatureToggle { 84 | public init( 85 | key: String, 86 | defaultValue: T, 87 | userInfo: [String: Any] = [:], 88 | debugValues: [AnyHashable] = [], 89 | debugDescription: String = "", 90 | get: @escaping Getter 91 | ) { 92 | self.key = key 93 | self.defaultValue = defaultValue 94 | self.userInfo = userInfo 95 | self.debugValues = debugValues 96 | self.debugDescription = debugDescription 97 | self.get = get 98 | self.provider = P.makeProvider() 99 | } 100 | 101 | public init( 102 | key: Key, 103 | defaultValue: T, 104 | userInfo: [String: Any] = [:], 105 | debugValues: [AnyHashable] = [], 106 | debugDescription: String = "", 107 | get: @escaping Getter 108 | ) where Key.RawValue == String { 109 | self.init( 110 | key: key.rawValue, 111 | defaultValue: defaultValue, 112 | userInfo: userInfo, 113 | debugValues: debugValues, 114 | debugDescription: debugDescription, 115 | get: get 116 | ) 117 | } 118 | } 119 | 120 | public extension ProviderResolvingFeatureToggle where P == EmptyToggleProviderResolving, T: ExpressibleByBooleanLiteral, T.BooleanLiteralType == Bool { 121 | init( 122 | key: String, 123 | defaultValue: T = false, 124 | userInfo: [String: Any] = [:], 125 | debugDescription: String = "", 126 | provider: ToggleProvider 127 | ) { 128 | self.init( 129 | key: key, 130 | defaultValue: defaultValue, 131 | userInfo: userInfo, 132 | debugDescription: debugDescription, 133 | get: { try T(booleanLiteral: $0.decode()) }, 134 | provider: provider 135 | ) 136 | } 137 | 138 | init( 139 | key: Key, 140 | defaultValue: T = false, 141 | userInfo: [String: Any] = [:], 142 | debugDescription: String = "", 143 | provider: ToggleProvider 144 | ) where Key.RawValue == String { 145 | self.init( 146 | key: key.rawValue, 147 | defaultValue: defaultValue, 148 | userInfo: userInfo, 149 | debugDescription: debugDescription, 150 | provider: provider 151 | ) 152 | } 153 | } 154 | 155 | public extension ProviderResolvingFeatureToggle where T: ExpressibleByBooleanLiteral, T.BooleanLiteralType == Bool { 156 | init( 157 | key: String, 158 | defaultValue: T = false, 159 | userInfo: [String: Any] = [:], 160 | debugDescription: String = "" 161 | ) { 162 | self.init( 163 | key: key, 164 | defaultValue: defaultValue, 165 | userInfo: userInfo, 166 | debugDescription: debugDescription, 167 | get: { try T(booleanLiteral: $0.decode()) } 168 | ) 169 | } 170 | 171 | init( 172 | key: Key, 173 | defaultValue: T = false, 174 | userInfo: [String: Any] = [:], 175 | debugDescription: String = "" 176 | ) where Key.RawValue == String { 177 | self.init( 178 | key: key.rawValue, 179 | defaultValue: defaultValue, 180 | userInfo: userInfo, 181 | debugDescription: debugDescription 182 | ) 183 | } 184 | } 185 | 186 | public extension ProviderResolvingFeatureToggle where P == EmptyToggleProviderResolving, T: ExpressibleByStringLiteral, T.StringLiteralType == String { 187 | init( 188 | key: String, 189 | defaultValue: T = "", 190 | userInfo: [String: Any] = [:], 191 | debugValues: [T] = [], 192 | debugDescription: String = "", 193 | provider: ToggleProvider 194 | ) { 195 | self.init( 196 | key: key, 197 | defaultValue: defaultValue, 198 | userInfo: userInfo, 199 | debugValues: debugValues, 200 | debugDescription: debugDescription, 201 | get: { try T(stringLiteral: $0.decode()) }, 202 | provider: provider 203 | ) 204 | } 205 | 206 | init( 207 | key: Key, 208 | defaultValue: T = "", 209 | userInfo: [String: Any] = [:], 210 | debugValues: [T] = [], 211 | debugDescription: String = "", 212 | provider: ToggleProvider 213 | ) where Key.RawValue == String { 214 | self.init( 215 | key: key.rawValue, 216 | defaultValue: defaultValue, 217 | userInfo: userInfo, 218 | debugValues: debugValues, 219 | debugDescription: debugDescription, 220 | provider: provider 221 | ) 222 | } 223 | } 224 | 225 | public extension ProviderResolvingFeatureToggle where T: ExpressibleByStringLiteral, T.StringLiteralType == String { 226 | init( 227 | key: String, 228 | defaultValue: T = "", 229 | userInfo: [String: Any] = [:], 230 | debugValues: [T] = [], 231 | debugDescription: String = "" 232 | ) { 233 | self.init( 234 | key: key, 235 | defaultValue: defaultValue, 236 | userInfo: userInfo, 237 | debugValues: debugValues, 238 | debugDescription: debugDescription, 239 | get: { try T(stringLiteral: $0.decode()) } 240 | ) 241 | } 242 | 243 | init( 244 | key: Key, 245 | defaultValue: T = "", 246 | userInfo: [String: Any] = [:], 247 | debugValues: [T] = [], 248 | debugDescription: String = "" 249 | ) where Key.RawValue == String { 250 | self.init( 251 | key: key.rawValue, 252 | defaultValue: defaultValue, 253 | userInfo: userInfo, 254 | debugValues: debugValues, 255 | debugDescription: debugDescription 256 | ) 257 | } 258 | } 259 | 260 | extension ProviderResolvingFeatureToggle where P == EmptyToggleProviderResolving, T: RawRepresentable & CaseIterable, T.RawValue == String { 261 | public init( 262 | key: String, 263 | defaultValue: T, 264 | userInfo: [String: Any] = [:], 265 | debugDescription: String = "", 266 | provider: ToggleProvider 267 | ) { 268 | self.init( 269 | key: key, 270 | defaultValue: defaultValue, 271 | userInfo: userInfo, 272 | debugValues: Array(T.allCases).map { $0.rawValue }, 273 | debugDescription: debugDescription, 274 | get: { try T(rawValue: $0.decode()) ?? defaultValue }, 275 | provider: provider 276 | ) 277 | } 278 | 279 | public init( 280 | key: Key, 281 | defaultValue: T, 282 | userInfo: [String: Any] = [:], 283 | debugDescription: String = "", 284 | provider: ToggleProvider 285 | ) where Key.RawValue == String { 286 | self.init( 287 | key: key.rawValue, 288 | defaultValue: defaultValue, 289 | userInfo: userInfo, 290 | debugDescription: debugDescription, 291 | provider: provider 292 | ) 293 | } 294 | } 295 | 296 | extension ProviderResolvingFeatureToggle where T: RawRepresentable & CaseIterable, T.RawValue == String { 297 | public init( 298 | key: String, 299 | defaultValue: T, 300 | userInfo: [String: Any] = [:], 301 | debugDescription: String = "" 302 | ) { 303 | self.init( 304 | key: key, 305 | defaultValue: defaultValue, 306 | userInfo: userInfo, 307 | debugValues: Array(T.allCases).map { $0.rawValue }, 308 | debugDescription: debugDescription, 309 | get: { try T(rawValue: $0.decode()) ?? defaultValue } 310 | ) 311 | } 312 | 313 | public init( 314 | key: Key, 315 | defaultValue: T, 316 | userInfo: [String: Any] = [:], 317 | debugDescription: String = "" 318 | ) where Key.RawValue == String { 319 | self.init( 320 | key: key.rawValue, 321 | defaultValue: defaultValue, 322 | userInfo: userInfo, 323 | debugDescription: debugDescription 324 | ) 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/FeatureToggle/ObservableFeatureToggle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// Feature toggle property wrapper that accepts toggle provider as constructor parameter 5 | public typealias ObservableFeatureToggle = ProviderResolvingObservableFeatureToggle 6 | 7 | /// Feature toggle that provides observable value 8 | @propertyWrapper @dynamicMemberLookup 9 | public struct ProviderResolvingObservableFeatureToggle { 10 | public var toggle: ProviderResolvingFeatureToggle 11 | 12 | /// Publisher that publishes effective value of the feature toggle or its default value if computing effective value throws an error 13 | public let wrappedValue: AnyPublisher 14 | 15 | /// Feature toggle itself 16 | /// - Note: Setter only updates default value of the feature toggle, should be only used in `WithDefaults` implementation 17 | public var projectedValue: Self { 18 | get { self } 19 | set { self.toggle.defaultValue = newValue.toggle.defaultValue } 20 | } 21 | 22 | public init(toggle: ProviderResolvingFeatureToggle) { 23 | self.toggle = toggle 24 | self.wrappedValue = toggle.provider.effectiveValue(for: toggle) 25 | .map { (try? $0.get()) ?? toggle.defaultValue } 26 | .receive(on: DispatchQueue.main) 27 | .removeDuplicates() 28 | .eraseToAnyPublisher() 29 | } 30 | 31 | public var defaultValue: T { 32 | get { toggle.defaultValue } 33 | set { self.toggle.defaultValue = newValue } 34 | } 35 | 36 | public subscript(dynamicMember keyPath: KeyPath, U>) -> U { 37 | toggle[keyPath: keyPath] 38 | } 39 | 40 | /// Overrides value for the feature toggle key by setting it with `override` provider, should be only used in `WithDefaults` implementation 41 | public mutating func setValue(_ value: T) { 42 | toggle.setValue(value) 43 | } 44 | } 45 | 46 | extension ProviderResolvingObservableFeatureToggle where P == EmptyToggleProviderResolving { 47 | public init( 48 | key: String, 49 | defaultValue: T, 50 | userInfo: [String: Any] = [:], 51 | debugValues: [AnyHashable] = [], 52 | debugDescription: String = "", 53 | get: @escaping FeatureToggle.Getter, 54 | provider: ToggleProvider 55 | ) { 56 | self.init(toggle: FeatureToggle( 57 | key: key, 58 | defaultValue: defaultValue, 59 | userInfo: userInfo, 60 | debugValues: debugValues, 61 | debugDescription: debugDescription, 62 | get: get, 63 | provider: provider 64 | )) 65 | } 66 | 67 | public init( 68 | key: Key, 69 | defaultValue: T, 70 | userInfo: [String: Any] = [:], 71 | debugValues: [AnyHashable] = [], 72 | debugDescription: String = "", 73 | get: @escaping FeatureToggle.Getter, 74 | provider: ToggleProvider 75 | ) where Key.RawValue == String { 76 | self.init( 77 | key: key.rawValue, 78 | defaultValue: defaultValue, 79 | userInfo: userInfo, 80 | debugValues: debugValues, 81 | debugDescription: debugDescription, 82 | get: get, 83 | provider: provider 84 | ) 85 | } 86 | } 87 | 88 | extension ProviderResolvingObservableFeatureToggle { 89 | public init( 90 | key: String, 91 | defaultValue: T, 92 | userInfo: [String: Any] = [:], 93 | debugValues: [AnyHashable] = [], 94 | debugDescription: String = "", 95 | get: @escaping ProviderResolvingFeatureToggle.Getter 96 | ) { 97 | self.init(toggle: ProviderResolvingFeatureToggle( 98 | key: key, 99 | defaultValue: defaultValue, 100 | userInfo: userInfo, 101 | debugValues: debugValues, 102 | debugDescription: debugDescription, 103 | get: get 104 | )) 105 | } 106 | 107 | public init( 108 | key: Key, 109 | defaultValue: T, 110 | userInfo: [String: Any] = [:], 111 | debugValues: [AnyHashable] = [], 112 | debugDescription: String = "", 113 | get: @escaping ProviderResolvingFeatureToggle.Getter 114 | ) where Key.RawValue == String { 115 | self.init( 116 | key: key.rawValue, 117 | defaultValue: defaultValue, 118 | userInfo: userInfo, 119 | debugValues: debugValues, 120 | debugDescription: debugDescription, 121 | get: get 122 | ) 123 | } 124 | } 125 | 126 | public extension ProviderResolvingObservableFeatureToggle where P == EmptyToggleProviderResolving, T: ExpressibleByBooleanLiteral, T.BooleanLiteralType == Bool { 127 | init( 128 | key: String, 129 | defaultValue: T = false, 130 | userInfo: [String: Any] = [:], 131 | debugDescription: String = "", 132 | provider: ToggleProvider 133 | ) { 134 | self.init(toggle: FeatureToggle( 135 | key: key, 136 | defaultValue: defaultValue, 137 | userInfo: userInfo, 138 | debugDescription: debugDescription, 139 | provider: provider 140 | )) 141 | } 142 | 143 | init( 144 | key: Key, 145 | defaultValue: T = false, 146 | userInfo: [String: Any] = [:], 147 | debugDescription: String = "", 148 | provider: ToggleProvider 149 | ) where Key.RawValue == String { 150 | self.init( 151 | key: key.rawValue, 152 | defaultValue: defaultValue, 153 | userInfo: userInfo, 154 | debugDescription: debugDescription, 155 | provider: provider 156 | ) 157 | } 158 | } 159 | 160 | public extension ProviderResolvingObservableFeatureToggle where T: ExpressibleByBooleanLiteral, T.BooleanLiteralType == Bool { 161 | init( 162 | key: String, 163 | defaultValue: T = false, 164 | userInfo: [String: Any] = [:], 165 | debugDescription: String = "" 166 | ) { 167 | self.init(toggle: ProviderResolvingFeatureToggle( 168 | key: key, 169 | defaultValue: defaultValue, 170 | userInfo: userInfo, 171 | debugDescription: debugDescription 172 | )) 173 | } 174 | 175 | init( 176 | key: Key, 177 | defaultValue: T = false, 178 | userInfo: [String: Any] = [:], 179 | debugDescription: String = "" 180 | ) where Key.RawValue == String { 181 | self.init( 182 | key: key.rawValue, 183 | defaultValue: defaultValue, 184 | userInfo: userInfo, 185 | debugDescription: debugDescription 186 | ) 187 | } 188 | } 189 | 190 | public extension ProviderResolvingObservableFeatureToggle where P == EmptyToggleProviderResolving, T: ExpressibleByStringLiteral, T.StringLiteralType == String { 191 | init( 192 | key: String, 193 | defaultValue: T = "", 194 | userInfo: [String: Any] = [:], 195 | debugValues: [T] = [], 196 | debugDescription: String = "", 197 | provider: ToggleProvider 198 | ) { 199 | self.init(toggle: FeatureToggle( 200 | key: key, 201 | defaultValue: defaultValue, 202 | userInfo: userInfo, 203 | debugValues: debugValues, 204 | debugDescription: debugDescription, 205 | provider: provider 206 | )) 207 | } 208 | 209 | init( 210 | key: Key, 211 | defaultValue: T = "", 212 | userInfo: [String: Any] = [:], 213 | debugValues: [T] = [], 214 | debugDescription: String = "", 215 | provider: ToggleProvider 216 | ) where Key.RawValue == String { 217 | self.init( 218 | key: key.rawValue, 219 | defaultValue: defaultValue, 220 | userInfo: userInfo, 221 | debugValues: debugValues, 222 | debugDescription: debugDescription, 223 | provider: provider 224 | ) 225 | } 226 | } 227 | 228 | public extension ProviderResolvingObservableFeatureToggle where T: ExpressibleByStringLiteral, T.StringLiteralType == String { 229 | init( 230 | key: String, 231 | defaultValue: T = "", 232 | userInfo: [String: Any] = [:], 233 | debugValues: [T] = [], 234 | debugDescription: String = "" 235 | ) { 236 | self.init(toggle: ProviderResolvingFeatureToggle( 237 | key: key, 238 | defaultValue: defaultValue, 239 | userInfo: userInfo, 240 | debugValues: debugValues, 241 | debugDescription: debugDescription 242 | )) 243 | } 244 | 245 | init( 246 | key: Key, 247 | defaultValue: T = "", 248 | userInfo: [String: Any] = [:], 249 | debugValues: [T] = [], 250 | debugDescription: String = "" 251 | ) where Key.RawValue == String { 252 | self.init( 253 | key: key.rawValue, 254 | defaultValue: defaultValue, 255 | userInfo: userInfo, 256 | debugValues: debugValues, 257 | debugDescription: debugDescription 258 | ) 259 | } 260 | } 261 | 262 | extension ProviderResolvingObservableFeatureToggle where P == EmptyToggleProviderResolving, T: RawRepresentable & CaseIterable, T.RawValue == String { 263 | public init( 264 | key: String, 265 | defaultValue: T, 266 | userInfo: [String: Any] = [:], 267 | debugDescription: String = "", 268 | provider: ToggleProvider 269 | ) { 270 | self.init(toggle: FeatureToggle( 271 | key: key, 272 | defaultValue: defaultValue, 273 | userInfo: userInfo, 274 | debugValues: Array(T.allCases).map { $0.rawValue }, 275 | debugDescription: debugDescription, 276 | get: { try T(rawValue: $0.decode()) ?? defaultValue }, 277 | provider: provider 278 | )) 279 | } 280 | 281 | public init( 282 | key: Key, 283 | defaultValue: T, 284 | userInfo: [String: Any] = [:], 285 | debugDescription: String = "", 286 | provider: ToggleProvider 287 | ) where Key.RawValue == String { 288 | self.init( 289 | key: key.rawValue, 290 | defaultValue: defaultValue, 291 | userInfo: userInfo, 292 | debugDescription: debugDescription, 293 | provider: provider 294 | ) 295 | } 296 | } 297 | 298 | extension ProviderResolvingObservableFeatureToggle where T: RawRepresentable & CaseIterable, T.RawValue == String { 299 | public init( 300 | key: String, 301 | defaultValue: T, 302 | userInfo: [String: Any] = [:], 303 | debugDescription: String = "" 304 | ) { 305 | self.init(toggle: ProviderResolvingFeatureToggle( 306 | key: key, 307 | defaultValue: defaultValue, 308 | userInfo: userInfo, 309 | debugValues: Array(T.allCases).map { $0.rawValue }, 310 | debugDescription: debugDescription, 311 | get: { try T(rawValue: $0.decode()) ?? defaultValue } 312 | )) 313 | } 314 | 315 | public init( 316 | key: Key, 317 | defaultValue: T, 318 | userInfo: [String: Any] = [:], 319 | debugDescription: String = "" 320 | ) where Key.RawValue == String { 321 | self.init( 322 | key: key.rawValue, 323 | defaultValue: defaultValue, 324 | userInfo: userInfo, 325 | debugDescription: debugDescription 326 | ) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/ToggleDecoder/DictionaryToggleDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Decoder that decodes values from a dictionary 4 | public struct DictionaryToggleDecoder: ToggleDecoder { 5 | public let values: [String: Any] 6 | public var key: String 7 | 8 | public init(values: [String: Any], key: String) { 9 | self.values = values 10 | self.key = key 11 | } 12 | 13 | public func decode() throws -> Bool { 14 | try decode(key: "") 15 | } 16 | 17 | public func decode() throws -> String { 18 | try decode(key: "") 19 | } 20 | 21 | public func decode() throws -> Int { 22 | try decode(key: "") 23 | } 24 | 25 | public func decode() throws -> Float { 26 | try decode(key: "") 27 | } 28 | 29 | public func decode(key: String) throws -> Bool { 30 | let key = [self.key, key].filter { !$0.isEmpty }.joined() 31 | let value = try read(values: values, key: key) 32 | 33 | if let bool = value as? Bool { 34 | return bool 35 | } else if let string = value as? String { 36 | switch string.lowercased() { 37 | case "true", "yes", "1": 38 | return true 39 | default: 40 | return false 41 | } 42 | } else { 43 | throw FeatureToggleDecodingError.typeMismatch(key: key, type: type(of: value), expected: Bool.self) 44 | } 45 | } 46 | 47 | public func decode(key: String) throws -> String { 48 | let key = [self.key, key].filter { !$0.isEmpty }.joined() 49 | let value = try read(values: values, key: key) 50 | 51 | guard let string = value as? String else { 52 | throw FeatureToggleDecodingError.typeMismatch( 53 | key: key, 54 | type: type(of: value), 55 | expected: String.self 56 | ) 57 | } 58 | return string 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/ToggleDecoder/ToggleDecoder.swift: -------------------------------------------------------------------------------- 1 | public protocol ToggleDecoder { 2 | /// A root key for this decoder to decode value from 3 | var key: String { get set } 4 | 5 | /// Decode boolean value from a root key of this decoder 6 | func decode() throws -> Bool 7 | /// Decode string value from a root key of this decoder 8 | func decode() throws -> String 9 | 10 | /// Decode boolean value from a nested key 11 | func decode(key: String) throws -> Bool 12 | /// Decode string value from a nested key 13 | func decode(key: String) throws -> String 14 | } 15 | 16 | extension ToggleDecoder { 17 | public func decode(key: String) throws -> T { 18 | let value: String = try decode(key: key) 19 | guard let converted = T(value) else { 20 | throw FeatureToggleDecodingError.typeMismatch(key: key, type: type(of: value), expected: T.self) 21 | } 22 | return converted 23 | } 24 | } 25 | 26 | public enum FeatureToggleDecodingError: Error { 27 | case keyNotFound(key: String) 28 | case typeMismatch(key: String, type: Any.Type, expected: Any.Type) 29 | } 30 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/ToggleProvider/ToggleProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// Provides actual values for feature toggles 5 | public protocol ToggleProvider { 6 | /// Name of this provider for display in DebugView 7 | var name: String { get } 8 | /// Provider of actual values, by default returns `self` 9 | var provider: ToggleProvider { get } 10 | /// Provider for override values, by default returns `DefaultToggleProvider` 11 | var override: ToggleProvider & ToggleOverriding { get } 12 | 13 | /// Performs necessary setup for this provider, i.e. initialising and configuring 3rd party services. 14 | /// Emits a void value and completes when setup is complete 15 | func setUp() -> AnyPublisher 16 | /// Refreshes underlying source of values, i.e. by resetting any cache that this provider may maintain or reaching to remote service for up to date values. 17 | /// Emits a void value and completes when setup is complete 18 | func refresh() -> AnyPublisher 19 | 20 | /// Perform necessary cleanup, i.e. discarding any data associated with the current user session 21 | func tearDown() 22 | 23 | func value(for toggle: ProviderResolvingFeatureToggle) throws -> T 24 | func value(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> 25 | func decoder(for toggle: ProviderResolvingFeatureToggle) throws -> ToggleDecoder 26 | func decoder(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> 27 | 28 | func value(for group: ProviderResolvingFeatureGroup) throws -> T 29 | func value(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> 30 | func decoder(for group: ProviderResolvingFeatureGroup) throws -> ToggleDecoder 31 | func decoder(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> 32 | } 33 | 34 | public extension ToggleProvider { 35 | var name: String { "\(type(of: self))" } 36 | 37 | var provider: ToggleProvider { self } 38 | var override: ToggleProvider & ToggleOverriding { DefaultToggleProvider() } 39 | 40 | func setUp() -> AnyPublisher { Empty().eraseToAnyPublisher() } 41 | func refresh() -> AnyPublisher { Empty().eraseToAnyPublisher() } 42 | 43 | func tearDown() {} 44 | 45 | func value(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 46 | Result { try value(for: toggle) }.publisher 47 | } 48 | 49 | func value(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 50 | Result { try value(for: group) }.publisher 51 | } 52 | 53 | func decoder(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 54 | Result { try decoder(for: toggle) }.publisher 55 | } 56 | 57 | func decoder(for group: ProviderResolvingFeatureGroup) throws -> ToggleDecoder { 58 | try FeatureGroupDecoder( 59 | key: group.toggle.key, 60 | providerDecoder: self.decoder(for: group.toggle), 61 | overrideDecoder: self.override.decoder(for: group.toggle) 62 | ) 63 | } 64 | 65 | func decoder(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 66 | Result { try decoder(for: group) }.publisher 67 | } 68 | 69 | func effectiveValue(for toggle: ProviderResolvingFeatureToggle) throws -> T { 70 | try override.hasValue(for: toggle.key) 71 | ? override.value(for: toggle) 72 | : provider.value(for: toggle) 73 | } 74 | 75 | func effectiveValue(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 76 | override.value(for: toggle).flatMap { [provider] (overrideValue) -> AnyPublisher, Never> in 77 | overrideValue.isFailure 78 | ? provider.value(for: toggle) 79 | : Just(overrideValue).eraseToAnyPublisher() 80 | }.eraseToAnyPublisher() 81 | } 82 | 83 | func effectiveValue(for group: ProviderResolvingFeatureGroup) throws -> T { 84 | try group.toggle.get(decoder(for: group)) 85 | } 86 | 87 | func effectiveValue(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 88 | return Publishers.CombineLatest( 89 | decoder(for: group.toggle), 90 | override.decoder(for: group.toggle) 91 | ).map { (providerDecoder, overrideDecoder) -> Result in 92 | Result { 93 | switch (providerDecoder, overrideDecoder) { 94 | case let (.success(providerDecoder), .success(overrideDecoder)): 95 | return FeatureGroupDecoder( 96 | key: group.toggle.key, 97 | providerDecoder: providerDecoder, 98 | overrideDecoder: overrideDecoder 99 | ) 100 | case (.success(let decoder), .failure): 101 | return decoder 102 | case (.failure, let .success(decoder)): 103 | return decoder 104 | case (.failure(let error), .failure): 105 | throw error 106 | } 107 | }.flatMap { decoder in 108 | Result { 109 | try group.toggle.get(decoder) 110 | } 111 | } 112 | }.eraseToAnyPublisher() 113 | } 114 | } 115 | 116 | extension Result { 117 | var publisher: AnyPublisher { 118 | Publishers.Sequence(sequence: [self]).eraseToAnyPublisher() 119 | } 120 | 121 | var isFailure: Bool { 122 | if case .failure = self { return true } 123 | else { return false } 124 | } 125 | } 126 | 127 | public protocol ToggleOverriding { 128 | func hasValue(for key: String) -> Bool 129 | func setValue(_ value: Any?, forKey key: String) 130 | } 131 | 132 | public struct OverridableToggleProvider: ToggleProvider, ToggleOverriding { 133 | public let provider: ToggleProvider 134 | public let override: ToggleOverriding & ToggleProvider 135 | 136 | public var name: String { provider.name } 137 | 138 | public init( 139 | provider: ToggleProvider, 140 | override: ToggleOverriding & ToggleProvider 141 | ) { 142 | self.provider = provider 143 | self.override = override 144 | } 145 | 146 | public func value(for toggle: ProviderResolvingFeatureToggle) throws -> T where T : Hashable { 147 | try provider.value(for: toggle) 148 | } 149 | 150 | public func value(for group: ProviderResolvingFeatureGroup) throws -> T { 151 | try provider.value(for: group) 152 | } 153 | 154 | public func value(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 155 | provider.value(for: toggle) 156 | } 157 | 158 | public func value(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 159 | provider.value(for: group) 160 | } 161 | 162 | public func decoder(for toggle: ProviderResolvingFeatureToggle) throws -> ToggleDecoder { 163 | try provider.decoder(for: toggle) 164 | } 165 | 166 | public func decoder(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 167 | provider.decoder(for: toggle) 168 | } 169 | 170 | public func decoder(for group: ProviderResolvingFeatureGroup) throws -> ToggleDecoder { 171 | try provider.decoder(for: group) 172 | } 173 | 174 | public func decoder(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 175 | provider.decoder(for: group) 176 | } 177 | 178 | public func hasValue(for key: String) -> Bool { 179 | override.hasValue(for: key) 180 | } 181 | 182 | public func setValue(_ value: Any?, forKey key: String) { 183 | override.setValue(value, forKey: key) 184 | } 185 | } 186 | 187 | /// A toggle provider that always returns default values and does noting on override 188 | public struct DefaultToggleProvider: ToggleProvider, ToggleOverriding { 189 | public func value(for toggle: ProviderResolvingFeatureToggle) throws -> T { 190 | toggle.defaultValue 191 | } 192 | 193 | public func value(for group: ProviderResolvingFeatureGroup) throws -> T { 194 | group.toggle.defaultValue 195 | } 196 | 197 | public func hasValue(for key: String) -> Bool { 198 | false 199 | } 200 | 201 | public func setValue(_ value: Any?, forKey key: String) {} 202 | 203 | public func decoder(for toggle: ProviderResolvingFeatureToggle) -> ToggleDecoder { 204 | DictionaryToggleDecoder(values: [:], key: toggle.key) 205 | } 206 | } 207 | 208 | public enum InMemoryProviderResolver: ToggleProviderResolving { 209 | public static var provider = InMemoryToggleProvider(values: [:]) 210 | 211 | public static func makeProvider() -> ToggleProvider { 212 | provider 213 | } 214 | } 215 | 216 | public typealias InMemoryFeatureToggle = ProviderResolvingFeatureToggle 217 | public typealias InMemoryObservableFeatureToggle = ProviderResolvingObservableFeatureToggle 218 | public typealias InMemoryFeatureGroup = ProviderResolvingFeatureGroup 219 | public typealias InMemoryObservableFeatureGroup = ProviderResolvingObservableFeatureGroup 220 | 221 | /// A toggle provider that stores values in memory 222 | public struct InMemoryToggleProvider: ToggleProvider { 223 | public var values: [String: Any] 224 | 225 | public init(values: [String: Any]) { 226 | self.values = values 227 | } 228 | 229 | public func value(for toggle: ProviderResolvingFeatureToggle) throws -> T { 230 | try toggle.get(self.decoder(for: toggle)) 231 | } 232 | 233 | public func value(for group: ProviderResolvingFeatureGroup) throws -> T { 234 | try value(for: group.toggle) 235 | } 236 | 237 | public func decoder(for toggle: ProviderResolvingFeatureToggle) -> ToggleDecoder { 238 | DictionaryToggleDecoder(values: values, key: toggle.key) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/ToggleProvider/ToggleProviderResolving.swift: -------------------------------------------------------------------------------- 1 | public protocol ToggleProviderResolving { 2 | static func makeProvider() -> ToggleProvider 3 | } 4 | 5 | public struct EmptyToggleProviderResolving: ToggleProviderResolving { 6 | public static func makeProvider() -> ToggleProvider { 7 | fatalError("Not implemented, provider should be passed via constructor parameter") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/ToggleProvider/UserDefaultsToggleProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | public enum UserDefaultsProviderResolver: ToggleProviderResolving { 5 | public static var provider = UserDefaultsToggleProvider(userDefaults: .standard) 6 | 7 | public static func makeProvider() -> ToggleProvider { 8 | provider 9 | } 10 | } 11 | 12 | public typealias UserDefaultsFeatureToggle = ProviderResolvingFeatureToggle 13 | public typealias UserDefaultsObservableFeatureToggle = ProviderResolvingObservableFeatureToggle 14 | public typealias UserDefaultsFeatureGroup = ProviderResolvingFeatureGroup 15 | public typealias UserDefaultsObservableFeatureGroup = ProviderResolvingObservableFeatureGroup 16 | 17 | public class UserDefaultsToggleProvider: ToggleProvider, ToggleOverriding { 18 | public static let key = "toggles" 19 | public let userDefaults: UserDefaults 20 | private var values: [String: Any] { 21 | didSet { subject.send(values) } 22 | } 23 | private let subject: CurrentValueSubject<[String: Any], Never> 24 | 25 | public init(userDefaults: UserDefaults) { 26 | self.userDefaults = userDefaults 27 | let values = userDefaults.dictionary(forKey: Self.key) ?? [:] 28 | self.values = values 29 | self.subject = CurrentValueSubject(values) 30 | } 31 | 32 | public func hasValue(for key: String) -> Bool { 33 | (try? read(values: values, key: key)) != nil 34 | } 35 | 36 | public func setValue(_ value: Any?, forKey key: String) { 37 | var values: Any? = self.values 38 | try? write(values: &values, key: key, value: value) 39 | userDefaults.setValue(values, forKey: Self.key) 40 | userDefaults.synchronize() 41 | self.values = values as! [String: Any] 42 | } 43 | 44 | public func value(for toggle: ProviderResolvingFeatureToggle) throws -> T { 45 | try toggle.get(self.decoder(for: toggle)) 46 | } 47 | 48 | public func value(for group: ProviderResolvingFeatureGroup) throws -> T { 49 | try value(for: group.toggle) 50 | } 51 | 52 | public func decoder(for toggle: ProviderResolvingFeatureToggle) -> ToggleDecoder { 53 | DictionaryToggleDecoder(values: values, key: toggle.key) 54 | } 55 | 56 | public func decoder(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 57 | subject.map { values in 58 | .success(self.decoder(for: toggle)) 59 | }.eraseToAnyPublisher() 60 | } 61 | 62 | public func decoder(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 63 | subject.map { values in 64 | Result { try self.decoder(for: group) } 65 | }.eraseToAnyPublisher() 66 | } 67 | 68 | public func value(for toggle: ProviderResolvingFeatureToggle) -> AnyPublisher, Never> { 69 | subject.map { _ in 70 | Result { 71 | try self.value(for: toggle) 72 | } 73 | }.eraseToAnyPublisher() 74 | } 75 | 76 | public func value(for group: ProviderResolvingFeatureGroup) -> AnyPublisher, Never> { 77 | subject.map { _ in 78 | Result { 79 | try self.value(for: group) 80 | } 81 | }.eraseToAnyPublisher() 82 | } 83 | } 84 | 85 | func asDictionary(_ value: Any?, key: String) throws -> [String: Any] { 86 | guard let dict = value as? [String: Any] else { 87 | throw FeatureToggleDecodingError.typeMismatch( 88 | key: key, 89 | type: type(of: value), 90 | expected: [String: Any].self 91 | ) 92 | } 93 | return dict 94 | } 95 | 96 | func asArray(_ value: Any?, index: Int) throws -> [Any?] { 97 | guard let array = value as? [Any?] else { 98 | throw FeatureToggleDecodingError.typeMismatch( 99 | key: "\(index)", 100 | type: type(of: value), 101 | expected: [Any?].self 102 | ) 103 | } 104 | return array 105 | } 106 | 107 | func read(values: Any?, key: String) throws -> Any? { 108 | if key.isEmpty { 109 | return values 110 | } 111 | 112 | let (key, remainder) = CodingKeyPath(key).head 113 | 114 | if let index = key.intValue { 115 | let array = try asArray(values, index: index) 116 | guard index < array.count else { 117 | throw FeatureToggleDecodingError.keyNotFound(key: "\(index)") 118 | } 119 | return try read(values: array[index], key: remainder.stringValue) 120 | } else { 121 | let dict = try asDictionary(values, key: key.stringValue) 122 | guard let values = dict[key.stringValue] else { 123 | throw FeatureToggleDecodingError.keyNotFound(key: key.stringValue) 124 | } 125 | return try read(values: values, key: remainder.stringValue) 126 | } 127 | } 128 | 129 | func write(values: inout Any?, key: String, value: Any?) throws { 130 | if key.isEmpty { 131 | values = value 132 | return 133 | } 134 | 135 | let (key, remainder) = CodingKeyPath(key).head 136 | if let index = key.intValue { 137 | if values == nil { 138 | values = [] 139 | } 140 | var array = try asArray(values, index: index) 141 | if index >= array.count { 142 | array.append(contentsOf: [Any?].init(repeating: nil, count: index - array.count + 1)) 143 | } 144 | var item: Any? = array[index] 145 | try write(values: &item, key: remainder.stringValue, value: value) 146 | array[index] = item 147 | values = array 148 | } else { 149 | if values == nil { 150 | values = [String: Any]() 151 | } 152 | var dict = try asDictionary(values, key: key.stringValue) 153 | var item = dict[key.stringValue] 154 | try write(values: &item, key: remainder.stringValue, value: value) 155 | dict[key.stringValue] = item 156 | values = dict 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /ToggleUI/Sources/ToggleUI/WithDefaults.swift: -------------------------------------------------------------------------------- 1 | public protocol WithDefaults { 2 | } 3 | 4 | public extension WithDefaults { 5 | func withDefaults(_ defaults: (inout Self) -> Void = { _ in }) -> Self { 6 | var copy = self 7 | defaults(©) 8 | 9 | #if DEBUG 10 | ToggleUI.debugToggles = Mirror(reflecting: copy).children() 11 | .reduce(into: [DebugToggle]()) { (debugToggles, child: DebugToggleConvertible) in 12 | let debugToggle = child.debugToggle 13 | if !debugToggles.contains(where: { $0.id == debugToggle.id }) { 14 | debugToggles.append(debugToggle) 15 | } 16 | } 17 | #endif 18 | 19 | return copy 20 | } 21 | } 22 | 23 | public struct EmptyToggles: WithDefaults { 24 | public init() {} 25 | } 26 | -------------------------------------------------------------------------------- /ToggleUI/Tests/ToggleUITests/ToggleUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ToggleUI 3 | import Combine 4 | 5 | final class ToggleUILibTests: XCTestCase { 6 | 7 | let toggles = Toggles() 8 | 9 | var bag = Set() 10 | 11 | func testInMemoryToggleProvider() { 12 | currentProvider = inMemoryProvider 13 | 14 | XCTAssertEqual(toggles.toggleA, true) 15 | XCTAssertEqual(toggles.toggleB, "B") 16 | XCTAssertEqual(toggles.toggleC, .c) 17 | XCTAssertEqual(toggles.toggleF, "F") 18 | XCTAssertEqual(toggles.toggleG, true) 19 | 20 | XCTAssertEqual(toggles.$toggleD.toggle.wrappedValue, true) 21 | XCTAssertEqual(toggles.$toggleE.toggle.wrappedValue, .b) 22 | 23 | XCTAssertEqual(toggles.value1sync, "value1") 24 | 25 | XCTAssertEqual(toggles.toggleConfig.f, "F") 26 | XCTAssertEqual(toggles.toggleConfig.g, true) 27 | } 28 | 29 | func testUserDefaultsToggleProvider() { 30 | UserDefaults.standard.set(values, forKey: UserDefaultsToggleProvider.key) 31 | currentProvider = UserDefaultsToggleProvider( 32 | userDefaults: UserDefaults.standard 33 | ) 34 | 35 | XCTAssertEqual(toggles.toggleA, true) 36 | XCTAssertEqual(toggles.toggleB, "B") 37 | XCTAssertEqual(toggles.toggleC, .c) 38 | XCTAssertEqual(toggles.toggleF, "F") 39 | XCTAssertEqual(toggles.toggleG, true) 40 | 41 | XCTAssertEqual(toggles.$toggleD.toggle.wrappedValue, true) 42 | XCTAssertEqual(toggles.$toggleE.toggle.wrappedValue, .b) 43 | 44 | XCTAssertEqual(toggles.toggleConfig.f, "F") 45 | XCTAssertEqual(toggles.toggleConfig.g, true) 46 | 47 | UserDefaults.standard.setValue(nil, forKey: UserDefaultsToggleProvider.key) 48 | } 49 | 50 | func testObservers() { 51 | currentProvider = inMemoryProvider 52 | 53 | XCTAssertEqual(toggles.$value2.toggle.wrappedValue, true) 54 | XCTAssertEqual(toggles.$value3.toggle.wrappedValue, "value3") 55 | 56 | XCTAssertEqual(toggles.$value4.toggle.wrappedValue, "value4") 57 | XCTAssertEqual(toggles.$value1.toggle.wrappedValue, "value1") 58 | 59 | XCTAssertEqual(toggles.$value3Decodable.group.toggle.wrappedValue.feature3, "value3") 60 | 61 | let expValue2 = expectation(description: "") 62 | toggles.value2.sink { value in 63 | XCTAssertEqual(value, true) 64 | expValue2.fulfill() 65 | }.store(in: &bag) 66 | 67 | let expValue3 = expectation(description: "") 68 | toggles.value3.sink { value in 69 | XCTAssertEqual(value, "value3") 70 | expValue3.fulfill() 71 | }.store(in: &bag) 72 | 73 | let expValue4 = expectation(description: "") 74 | toggles.value4.sink { value in 75 | XCTAssertEqual(value, "value4") 76 | expValue4.fulfill() 77 | }.store(in: &bag) 78 | 79 | let expValue1 = expectation(description: "") 80 | toggles.value1.sink { value in 81 | XCTAssertEqual(value, "value1") 82 | expValue1.fulfill() 83 | }.store(in: &bag) 84 | 85 | let expValue3Decodable = expectation(description: "") 86 | toggles.value3Decodable.sink { value in 87 | XCTAssertEqual(value.feature3, "value3") 88 | expValue3Decodable.fulfill() 89 | }.store(in: &bag) 90 | 91 | waitForExpectations(timeout: 10, handler: nil) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /ToggleUI/Tests/ToggleUITests/Toggles.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ToggleUI 3 | import Combine 4 | 5 | let values: [String: Any] = [ 6 | "a": "true", 7 | "b": "B", 8 | "c": "c", 9 | "d": "true", 10 | "e": "b", 11 | "config": [ 12 | "f": "F", 13 | "g": true 14 | ] 15 | ] 16 | 17 | let inMemoryProvider = InMemoryToggleProvider(values: values) 18 | 19 | let userDefaultsProvider = UserDefaultsToggleProvider( 20 | userDefaults: UserDefaults.standard 21 | ) 22 | 23 | var currentProvider: ToggleProvider = OverridableToggleProvider( 24 | provider: inMemoryProvider, 25 | override: userDefaultsProvider 26 | ) 27 | 28 | struct Config: DecoderCapturing { 29 | enum CodingKeys: String, CodingKey { 30 | case decoder = "config" 31 | } 32 | 33 | @DecoderCaptured var decoder: Decoder 34 | } 35 | 36 | class URLSessionRemoteToggleProvider: CodableToggleProvider { 37 | var name: String = "Remote Config" 38 | typealias DecoderCaptured = T 39 | 40 | let dataPublisher: AnyPublisher, Never> = DataPublisher.publisher 41 | } 42 | 43 | class ConfigPublisher { 44 | var bag = Set() 45 | let publisher: AnyPublisher, Never> 46 | 47 | init() { 48 | let subject = CurrentValueSubject?, Never>(nil) 49 | URLSession.shared 50 | .dataTaskPublisher(for: URL(string: "https://api.jsonbin.io/b/5eb83d1747a2266b147628b7/5")!) 51 | .map { data, _ in data } 52 | .sink( 53 | receiveCompletion: { _ in }, 54 | receiveValue: { 55 | // subject.send(.failure(URLError.init(.notConnectedToInternet))) 56 | subject.send(.success($0)) 57 | } 58 | ).store(in: &bag) 59 | self.publisher = subject.filter { $0 != nil }.map { $0! }.eraseToAnyPublisher() 60 | } 61 | } 62 | 63 | let DataPublisher = ConfigPublisher() 64 | 65 | let urlConfigProvider = OverridableToggleProvider( 66 | provider: URLSessionRemoteToggleProvider(), 67 | override: userDefaultsProvider 68 | ) 69 | 70 | let urlProvider = OverridableToggleProvider( 71 | provider: URLSessionRemoteToggleProvider(), 72 | override: userDefaultsProvider 73 | ) 74 | 75 | struct RemoteToggleProviderResolver: ToggleProviderResolving { 76 | static func makeProvider() -> ToggleProvider { 77 | urlProvider 78 | } 79 | } 80 | 81 | struct RemoteConfigToggleProviderResolver: ToggleProviderResolving { 82 | static func makeProvider() -> ToggleProvider { 83 | urlConfigProvider 84 | } 85 | } 86 | 87 | typealias RemoteFeatureToggle = ProviderResolvingObservableFeatureToggle 88 | typealias RemoteFeatureGroup = ProviderResolvingObservableFeatureGroup 89 | typealias RemoteConfigFeatureToggle = ProviderResolvingObservableFeatureToggle 90 | typealias RemoteConfigFeatureGroup = ProviderResolvingObservableFeatureGroup 91 | 92 | typealias RemoteFeatureToggleValue = ProviderResolvingFeatureToggle 93 | 94 | struct Toggles: WithDefaults { 95 | 96 | enum ABTest: String, CaseIterable { 97 | case a, b, c 98 | } 99 | 100 | struct Module: FeatureGroupDecodable { 101 | @FeatureGroupProperty(key: "feature3") 102 | var feature3: String 103 | 104 | init() {} 105 | } 106 | 107 | struct Config: FeatureGroupDecodable { 108 | @FeatureGroupProperty(key: "f") 109 | var f: String 110 | @FeatureGroupProperty(key: "g") 111 | var g: Bool 112 | 113 | init() {} 114 | } 115 | 116 | @FeatureToggle(key: "a", provider: currentProvider) 117 | var toggleA: Bool 118 | 119 | @FeatureToggle(key: "b", provider: currentProvider) 120 | var toggleB: String 121 | 122 | @FeatureToggle(key: "c", defaultValue: .a, provider: currentProvider) 123 | var toggleC: ABTest 124 | 125 | @ObservableFeatureToggle(key: "d", provider: currentProvider) 126 | var toggleD: AnyPublisher 127 | 128 | @ObservableFeatureToggle(key: "e", defaultValue: .a, provider: currentProvider) 129 | var toggleE: AnyPublisher 130 | 131 | @FeatureToggle(key: "config.f", provider: currentProvider) 132 | var toggleF: String 133 | 134 | @FeatureToggle(key: "config.g", provider: currentProvider) 135 | var toggleG: Bool 136 | 137 | @FeatureGroup( 138 | key: "config", 139 | debugDescription: "Some config", 140 | provider: currentProvider 141 | ) 142 | var toggleConfig: Config 143 | 144 | @RemoteFeatureToggle(key: "feature1") 145 | var value1: AnyPublisher 146 | 147 | @RemoteFeatureToggleValue(key: "feature1") 148 | var value1sync: String 149 | 150 | @RemoteFeatureToggle(key: "feature4") 151 | var value4: AnyPublisher 152 | 153 | @RemoteConfigFeatureToggle(key: "feature2") 154 | var value2: AnyPublisher 155 | 156 | @RemoteConfigFeatureToggle(key: "module.feature3") 157 | var value3: AnyPublisher 158 | 159 | @RemoteConfigFeatureGroup(key: "module") 160 | var value3Decodable: AnyPublisher 161 | } 162 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | B500740824B10575000A3FB1 /* Toggles.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500740724B10575000A3FB1 /* Toggles.swift */; }; 11 | B500740C24B34F1E000A3FB1 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = B500740B24B34F1D000A3FB1 /* README.md */; }; 12 | B500740E24B34F62000A3FB1 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = B500740D24B34F62000A3FB1 /* LICENSE */; }; 13 | B5AFBA3F246C861000D17812 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AFBA3E246C861000D17812 /* AppDelegate.swift */; }; 14 | B5AFBA41246C861000D17812 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AFBA40246C861000D17812 /* SceneDelegate.swift */; }; 15 | B5AFBA43246C861000D17812 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AFBA42246C861000D17812 /* ContentView.swift */; }; 16 | B5AFBA45246C861500D17812 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5AFBA44246C861500D17812 /* Assets.xcassets */; }; 17 | B5AFBA48246C861500D17812 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5AFBA47246C861500D17812 /* Preview Assets.xcassets */; }; 18 | B5AFBA4B246C861500D17812 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5AFBA49246C861500D17812 /* LaunchScreen.storyboard */; }; 19 | B5AFBA58246C865E00D17812 /* ToggleUI in Frameworks */ = {isa = PBXBuildFile; productRef = B5AFBA57246C865E00D17812 /* ToggleUI */; }; 20 | /* End PBXBuildFile section */ 21 | 22 | /* Begin PBXFileReference section */ 23 | B500740724B10575000A3FB1 /* Toggles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Toggles.swift; path = ../../ToggleUI/Tests/ToggleUITests/Toggles.swift; sourceTree = ""; }; 24 | B500740B24B34F1D000A3FB1 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 25 | B500740D24B34F62000A3FB1 /* LICENSE */ = {isa = PBXFileReference; explicitFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; 26 | B5AFBA3B246C861000D17812 /* ToggleUIApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ToggleUIApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | B5AFBA3E246C861000D17812 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 28 | B5AFBA40246C861000D17812 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 29 | B5AFBA42246C861000D17812 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 30 | B5AFBA44246C861500D17812 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 31 | B5AFBA47246C861500D17812 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 32 | B5AFBA4A246C861500D17812 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 33 | B5AFBA4C246C861500D17812 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 34 | B5AFBA56246C865800D17812 /* ToggleUI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ToggleUI; path = ../ToggleUI; sourceTree = ""; }; 35 | /* End PBXFileReference section */ 36 | 37 | /* Begin PBXFrameworksBuildPhase section */ 38 | B5AFBA38246C861000D17812 /* Frameworks */ = { 39 | isa = PBXFrameworksBuildPhase; 40 | buildActionMask = 2147483647; 41 | files = ( 42 | B5AFBA58246C865E00D17812 /* ToggleUI in Frameworks */, 43 | ); 44 | runOnlyForDeploymentPostprocessing = 0; 45 | }; 46 | /* End PBXFrameworksBuildPhase section */ 47 | 48 | /* Begin PBXGroup section */ 49 | B5AFBA32246C861000D17812 = { 50 | isa = PBXGroup; 51 | children = ( 52 | B500740B24B34F1D000A3FB1 /* README.md */, 53 | B500740D24B34F62000A3FB1 /* LICENSE */, 54 | B5AFBA56246C865800D17812 /* ToggleUI */, 55 | B5AFBA3D246C861000D17812 /* ToggleUIApp */, 56 | B5AFBA3C246C861000D17812 /* Products */, 57 | B5AFBA52246C862600D17812 /* Frameworks */, 58 | ); 59 | sourceTree = ""; 60 | }; 61 | B5AFBA3C246C861000D17812 /* Products */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | B5AFBA3B246C861000D17812 /* ToggleUIApp.app */, 65 | ); 66 | name = Products; 67 | sourceTree = ""; 68 | }; 69 | B5AFBA3D246C861000D17812 /* ToggleUIApp */ = { 70 | isa = PBXGroup; 71 | children = ( 72 | B5AFBA3E246C861000D17812 /* AppDelegate.swift */, 73 | B500740724B10575000A3FB1 /* Toggles.swift */, 74 | B5AFBA40246C861000D17812 /* SceneDelegate.swift */, 75 | B5AFBA42246C861000D17812 /* ContentView.swift */, 76 | B5AFBA44246C861500D17812 /* Assets.xcassets */, 77 | B5AFBA49246C861500D17812 /* LaunchScreen.storyboard */, 78 | B5AFBA4C246C861500D17812 /* Info.plist */, 79 | B5AFBA46246C861500D17812 /* Preview Content */, 80 | ); 81 | path = ToggleUIApp; 82 | sourceTree = ""; 83 | }; 84 | B5AFBA46246C861500D17812 /* Preview Content */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | B5AFBA47246C861500D17812 /* Preview Assets.xcassets */, 88 | ); 89 | path = "Preview Content"; 90 | sourceTree = ""; 91 | }; 92 | B5AFBA52246C862600D17812 /* Frameworks */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | ); 96 | name = Frameworks; 97 | sourceTree = ""; 98 | }; 99 | /* End PBXGroup section */ 100 | 101 | /* Begin PBXNativeTarget section */ 102 | B5AFBA3A246C861000D17812 /* ToggleUIApp */ = { 103 | isa = PBXNativeTarget; 104 | buildConfigurationList = B5AFBA4F246C861500D17812 /* Build configuration list for PBXNativeTarget "ToggleUIApp" */; 105 | buildPhases = ( 106 | B5AFBA37246C861000D17812 /* Sources */, 107 | B5AFBA38246C861000D17812 /* Frameworks */, 108 | B5AFBA39246C861000D17812 /* Resources */, 109 | ); 110 | buildRules = ( 111 | ); 112 | dependencies = ( 113 | B5AFBA5A246C868F00D17812 /* PBXTargetDependency */, 114 | ); 115 | name = ToggleUIApp; 116 | packageProductDependencies = ( 117 | B5AFBA57246C865E00D17812 /* ToggleUI */, 118 | ); 119 | productName = ToggleUIApp; 120 | productReference = B5AFBA3B246C861000D17812 /* ToggleUIApp.app */; 121 | productType = "com.apple.product-type.application"; 122 | }; 123 | /* End PBXNativeTarget section */ 124 | 125 | /* Begin PBXProject section */ 126 | B5AFBA33246C861000D17812 /* Project object */ = { 127 | isa = PBXProject; 128 | attributes = { 129 | LastSwiftUpdateCheck = 1140; 130 | LastUpgradeCheck = 1140; 131 | ORGANIZATIONNAME = "Ilya Puchka"; 132 | TargetAttributes = { 133 | B5AFBA3A246C861000D17812 = { 134 | CreatedOnToolsVersion = 11.4; 135 | }; 136 | }; 137 | }; 138 | buildConfigurationList = B5AFBA36246C861000D17812 /* Build configuration list for PBXProject "ToggleUIApp" */; 139 | compatibilityVersion = "Xcode 9.3"; 140 | developmentRegion = en; 141 | hasScannedForEncodings = 0; 142 | knownRegions = ( 143 | en, 144 | Base, 145 | ); 146 | mainGroup = B5AFBA32246C861000D17812; 147 | productRefGroup = B5AFBA3C246C861000D17812 /* Products */; 148 | projectDirPath = ""; 149 | projectRoot = ""; 150 | targets = ( 151 | B5AFBA3A246C861000D17812 /* ToggleUIApp */, 152 | ); 153 | }; 154 | /* End PBXProject section */ 155 | 156 | /* Begin PBXResourcesBuildPhase section */ 157 | B5AFBA39246C861000D17812 /* Resources */ = { 158 | isa = PBXResourcesBuildPhase; 159 | buildActionMask = 2147483647; 160 | files = ( 161 | B5AFBA4B246C861500D17812 /* LaunchScreen.storyboard in Resources */, 162 | B5AFBA48246C861500D17812 /* Preview Assets.xcassets in Resources */, 163 | B500740C24B34F1E000A3FB1 /* README.md in Resources */, 164 | B5AFBA45246C861500D17812 /* Assets.xcassets in Resources */, 165 | B500740E24B34F62000A3FB1 /* LICENSE in Resources */, 166 | ); 167 | runOnlyForDeploymentPostprocessing = 0; 168 | }; 169 | /* End PBXResourcesBuildPhase section */ 170 | 171 | /* Begin PBXSourcesBuildPhase section */ 172 | B5AFBA37246C861000D17812 /* Sources */ = { 173 | isa = PBXSourcesBuildPhase; 174 | buildActionMask = 2147483647; 175 | files = ( 176 | B5AFBA3F246C861000D17812 /* AppDelegate.swift in Sources */, 177 | B500740824B10575000A3FB1 /* Toggles.swift in Sources */, 178 | B5AFBA41246C861000D17812 /* SceneDelegate.swift in Sources */, 179 | B5AFBA43246C861000D17812 /* ContentView.swift in Sources */, 180 | ); 181 | runOnlyForDeploymentPostprocessing = 0; 182 | }; 183 | /* End PBXSourcesBuildPhase section */ 184 | 185 | /* Begin PBXTargetDependency section */ 186 | B5AFBA5A246C868F00D17812 /* PBXTargetDependency */ = { 187 | isa = PBXTargetDependency; 188 | productRef = B5AFBA59246C868F00D17812 /* ToggleUI */; 189 | }; 190 | /* End PBXTargetDependency section */ 191 | 192 | /* Begin PBXVariantGroup section */ 193 | B5AFBA49246C861500D17812 /* LaunchScreen.storyboard */ = { 194 | isa = PBXVariantGroup; 195 | children = ( 196 | B5AFBA4A246C861500D17812 /* Base */, 197 | ); 198 | name = LaunchScreen.storyboard; 199 | sourceTree = ""; 200 | }; 201 | /* End PBXVariantGroup section */ 202 | 203 | /* Begin XCBuildConfiguration section */ 204 | B5AFBA4D246C861500D17812 /* Debug */ = { 205 | isa = XCBuildConfiguration; 206 | buildSettings = { 207 | ALWAYS_SEARCH_USER_PATHS = NO; 208 | CLANG_ANALYZER_NONNULL = YES; 209 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 210 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 211 | CLANG_CXX_LIBRARY = "libc++"; 212 | CLANG_ENABLE_MODULES = YES; 213 | CLANG_ENABLE_OBJC_ARC = YES; 214 | CLANG_ENABLE_OBJC_WEAK = YES; 215 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 216 | CLANG_WARN_BOOL_CONVERSION = YES; 217 | CLANG_WARN_COMMA = YES; 218 | CLANG_WARN_CONSTANT_CONVERSION = YES; 219 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 220 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 221 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 222 | CLANG_WARN_EMPTY_BODY = YES; 223 | CLANG_WARN_ENUM_CONVERSION = YES; 224 | CLANG_WARN_INFINITE_RECURSION = YES; 225 | CLANG_WARN_INT_CONVERSION = YES; 226 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 227 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 228 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 229 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 230 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 231 | CLANG_WARN_STRICT_PROTOTYPES = YES; 232 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 233 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 234 | CLANG_WARN_UNREACHABLE_CODE = YES; 235 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 236 | COPY_PHASE_STRIP = NO; 237 | DEBUG_INFORMATION_FORMAT = dwarf; 238 | ENABLE_STRICT_OBJC_MSGSEND = YES; 239 | ENABLE_TESTABILITY = YES; 240 | GCC_C_LANGUAGE_STANDARD = gnu11; 241 | GCC_DYNAMIC_NO_PIC = NO; 242 | GCC_NO_COMMON_BLOCKS = YES; 243 | GCC_OPTIMIZATION_LEVEL = 0; 244 | GCC_PREPROCESSOR_DEFINITIONS = ( 245 | "DEBUG=1", 246 | "$(inherited)", 247 | ); 248 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 249 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 250 | GCC_WARN_UNDECLARED_SELECTOR = YES; 251 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 252 | GCC_WARN_UNUSED_FUNCTION = YES; 253 | GCC_WARN_UNUSED_VARIABLE = YES; 254 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 255 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 256 | MTL_FAST_MATH = YES; 257 | ONLY_ACTIVE_ARCH = YES; 258 | SDKROOT = iphoneos; 259 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 260 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 261 | }; 262 | name = Debug; 263 | }; 264 | B5AFBA4E246C861500D17812 /* Release */ = { 265 | isa = XCBuildConfiguration; 266 | buildSettings = { 267 | ALWAYS_SEARCH_USER_PATHS = NO; 268 | CLANG_ANALYZER_NONNULL = YES; 269 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 270 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 271 | CLANG_CXX_LIBRARY = "libc++"; 272 | CLANG_ENABLE_MODULES = YES; 273 | CLANG_ENABLE_OBJC_ARC = YES; 274 | CLANG_ENABLE_OBJC_WEAK = YES; 275 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 276 | CLANG_WARN_BOOL_CONVERSION = YES; 277 | CLANG_WARN_COMMA = YES; 278 | CLANG_WARN_CONSTANT_CONVERSION = YES; 279 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 280 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 281 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 282 | CLANG_WARN_EMPTY_BODY = YES; 283 | CLANG_WARN_ENUM_CONVERSION = YES; 284 | CLANG_WARN_INFINITE_RECURSION = YES; 285 | CLANG_WARN_INT_CONVERSION = YES; 286 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 287 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 288 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 289 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 290 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 291 | CLANG_WARN_STRICT_PROTOTYPES = YES; 292 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 293 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 294 | CLANG_WARN_UNREACHABLE_CODE = YES; 295 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 296 | COPY_PHASE_STRIP = NO; 297 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 298 | ENABLE_NS_ASSERTIONS = NO; 299 | ENABLE_STRICT_OBJC_MSGSEND = YES; 300 | GCC_C_LANGUAGE_STANDARD = gnu11; 301 | GCC_NO_COMMON_BLOCKS = YES; 302 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 303 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 304 | GCC_WARN_UNDECLARED_SELECTOR = YES; 305 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 306 | GCC_WARN_UNUSED_FUNCTION = YES; 307 | GCC_WARN_UNUSED_VARIABLE = YES; 308 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 309 | MTL_ENABLE_DEBUG_INFO = NO; 310 | MTL_FAST_MATH = YES; 311 | SDKROOT = iphoneos; 312 | SWIFT_COMPILATION_MODE = wholemodule; 313 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 314 | VALIDATE_PRODUCT = YES; 315 | }; 316 | name = Release; 317 | }; 318 | B5AFBA50246C861500D17812 /* Debug */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 322 | CODE_SIGN_STYLE = Automatic; 323 | DEVELOPMENT_ASSET_PATHS = "\"ToggleUIApp/Preview Content\""; 324 | ENABLE_PREVIEWS = YES; 325 | INFOPLIST_FILE = ToggleUIApp/Info.plist; 326 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 327 | LD_RUNPATH_SEARCH_PATHS = ( 328 | "$(inherited)", 329 | "@executable_path/Frameworks", 330 | ); 331 | PRODUCT_BUNDLE_IDENTIFIER = ilya.puchka.ToggleUIApp; 332 | PRODUCT_NAME = "$(TARGET_NAME)"; 333 | SWIFT_VERSION = 5.0; 334 | TARGETED_DEVICE_FAMILY = "1,2"; 335 | }; 336 | name = Debug; 337 | }; 338 | B5AFBA51246C861500D17812 /* Release */ = { 339 | isa = XCBuildConfiguration; 340 | buildSettings = { 341 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 342 | CODE_SIGN_STYLE = Automatic; 343 | DEVELOPMENT_ASSET_PATHS = "\"ToggleUIApp/Preview Content\""; 344 | ENABLE_PREVIEWS = YES; 345 | INFOPLIST_FILE = ToggleUIApp/Info.plist; 346 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 347 | LD_RUNPATH_SEARCH_PATHS = ( 348 | "$(inherited)", 349 | "@executable_path/Frameworks", 350 | ); 351 | PRODUCT_BUNDLE_IDENTIFIER = ilya.puchka.ToggleUIApp; 352 | PRODUCT_NAME = "$(TARGET_NAME)"; 353 | SWIFT_VERSION = 5.0; 354 | TARGETED_DEVICE_FAMILY = "1,2"; 355 | }; 356 | name = Release; 357 | }; 358 | /* End XCBuildConfiguration section */ 359 | 360 | /* Begin XCConfigurationList section */ 361 | B5AFBA36246C861000D17812 /* Build configuration list for PBXProject "ToggleUIApp" */ = { 362 | isa = XCConfigurationList; 363 | buildConfigurations = ( 364 | B5AFBA4D246C861500D17812 /* Debug */, 365 | B5AFBA4E246C861500D17812 /* Release */, 366 | ); 367 | defaultConfigurationIsVisible = 0; 368 | defaultConfigurationName = Release; 369 | }; 370 | B5AFBA4F246C861500D17812 /* Build configuration list for PBXNativeTarget "ToggleUIApp" */ = { 371 | isa = XCConfigurationList; 372 | buildConfigurations = ( 373 | B5AFBA50246C861500D17812 /* Debug */, 374 | B5AFBA51246C861500D17812 /* Release */, 375 | ); 376 | defaultConfigurationIsVisible = 0; 377 | defaultConfigurationName = Release; 378 | }; 379 | /* End XCConfigurationList section */ 380 | 381 | /* Begin XCSwiftPackageProductDependency section */ 382 | B5AFBA57246C865E00D17812 /* ToggleUI */ = { 383 | isa = XCSwiftPackageProductDependency; 384 | productName = ToggleUI; 385 | }; 386 | B5AFBA59246C868F00D17812 /* ToggleUI */ = { 387 | isa = XCSwiftPackageProductDependency; 388 | productName = ToggleUI; 389 | }; 390 | /* End XCSwiftPackageProductDependency section */ 391 | }; 392 | rootObject = B5AFBA33246C861000D17812 /* Project object */; 393 | } 394 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp.xcodeproj/xcshareddata/xcschemes/ToggleUIApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ToggleUIApp 4 | // 5 | // Created by Ilya Puchka on 13/05/2020. 6 | // Copyright © 2020 Ilya Puchka. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | import ToggleUI 12 | 13 | let toggles = Toggles().withDefaults { (toggles) in 14 | toggles.$value3Decodable.defaultValue.$feature3 = "initial" 15 | } 16 | 17 | @UIApplicationMain 18 | class AppDelegate: UIResponder, UIApplicationDelegate { 19 | 20 | var bag = Set() 21 | 22 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 23 | // Override point for customization after application launch. 24 | 25 | // print(toggles.toggleConfig?.f) 26 | // print(toggles.toggleConfig?.g) 27 | // toggles.toggleD.sink(receiveValue: { 28 | // print($0) 29 | // }).store(in: &bag) 30 | // 31 | // toggles.value3Decodable.sink { 32 | // print($0?.feature3) 33 | // }.store(in: &bag) 34 | // 35 | // toggles.remoteConfig.sink { 36 | // print($0?.module?.feature3) 37 | // }.store(in: &bag) 38 | 39 | return true 40 | } 41 | 42 | // MARK: UISceneSession Lifecycle 43 | 44 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 45 | // Called when a new scene session is being created. 46 | // Use this method to select a configuration to create the new scene with. 47 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 48 | } 49 | 50 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 51 | // Called when the user discards a scene session. 52 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 53 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 54 | } 55 | 56 | 57 | } 58 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import ToggleUI 4 | 5 | struct DebugView_Previews: PreviewProvider { 6 | static var previews: some View { 7 | RootView() 8 | } 9 | } 10 | 11 | struct RootView: View { 12 | @State var showingDebugView: Bool = false 13 | 14 | @ObservedObject var toggleA = AnyFeatureToggle(toggles.$toggleA) 15 | @ObservedObject var toggleD = AnyMutableFeatureToggle(toggles.$toggleD) 16 | @ObservedObject var value1 = AnyMutableFeatureGroup(toggles.$value3Decodable) 17 | @ObservedObject var config = AnyFeatureGroup(toggles.$toggleConfig) 18 | 19 | var body: some View { 20 | NavigationView { 21 | Form { 22 | Toggle("toggleA", isOn: toggleA.binding) 23 | Toggle("toggleD", isOn: toggleD.binding) 24 | TextField("value1", text: value1.$feature3) 25 | Toggle("toggleG", isOn: config.$g) 26 | } 27 | .navigationBarTitle("Settings", displayMode: .inline) 28 | .navigationBarItems(trailing: Button("Debug") { 29 | self.showingDebugView = true 30 | }) 31 | }.sheet(isPresented: $showingDebugView) { 32 | ToggleUI.DebugView() 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ToggleUIApp/ToggleUIApp/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // ToggleUIApp 4 | // 5 | // Created by Ilya Puchka on 13/05/2020. 6 | // Copyright © 2020 Ilya Puchka. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import ToggleUI 12 | 13 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 18 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 19 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 20 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 21 | 22 | // Create the SwiftUI view that provides the window contents. 23 | let contentView = RootView() 24 | 25 | // Use a UIHostingController as window root view controller. 26 | if let windowScene = scene as? UIWindowScene { 27 | let window = UIWindow(windowScene: windowScene) 28 | window.rootViewController = UIHostingController(rootView: contentView) 29 | self.window = window 30 | window.makeKeyAndVisible() 31 | } 32 | } 33 | 34 | func sceneDidDisconnect(_ scene: UIScene) { 35 | // Called as the scene is being released by the system. 36 | // This occurs shortly after the scene enters the background, or when its session is discarded. 37 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 38 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 39 | } 40 | 41 | func sceneDidBecomeActive(_ scene: UIScene) { 42 | // Called when the scene has moved from an inactive state to an active state. 43 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 44 | } 45 | 46 | func sceneWillResignActive(_ scene: UIScene) { 47 | // Called when the scene will move from an active state to an inactive state. 48 | // This may occur due to temporary interruptions (ex. an incoming phone call). 49 | } 50 | 51 | func sceneWillEnterForeground(_ scene: UIScene) { 52 | // Called as the scene transitions from the background to the foreground. 53 | // Use this method to undo the changes made on entering the background. 54 | } 55 | 56 | func sceneDidEnterBackground(_ scene: UIScene) { 57 | // Called as the scene transitions from the foreground to the background. 58 | // Use this method to save data, release shared resources, and store enough scene-specific state information 59 | // to restore the scene back to its current state. 60 | } 61 | 62 | 63 | } 64 | 65 | -------------------------------------------------------------------------------- /debug-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilyapuchka/ToggleUI/c08216b530dfe07eb3bee3806ff6004e63faa6cb/debug-view.png --------------------------------------------------------------------------------