From eb4600e361c2ff76178218626e6aaaf8c2e45ff4 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 13:47:58 +0000 Subject: [PATCH 01/11] Fixed publishers being completed on override reset --- .../ToggleManager+Overrides.swift | 18 ++++++++++++++---- Sources/ToggleManager/ToggleManager.swift | 7 +++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Sources/ToggleManager/ToggleManager+Overrides.swift b/Sources/ToggleManager/ToggleManager+Overrides.swift index 38e7df5..3e8fcc6 100644 --- a/Sources/ToggleManager/ToggleManager+Overrides.swift +++ b/Sources/ToggleManager/ToggleManager+Overrides.swift @@ -18,14 +18,24 @@ extension ToggleManager { queue.sync(flags: .barrier) { guard let mutableValueProvider = mutableValueProvider else { return [] } let variables = mutableValueProvider.variables + log("Deleting all overrides.") + mutableValueProvider.deleteAll() + + // Clear cache for all variables that had overrides + for variable in variables { + cache[variable] = nil + } + + // Send updated values to existing subjects for variable in variables { DispatchQueue.main.async { - self.subjectsRefs[variable]?.send(completion: .finished) - self.subjectsRefs[variable] = nil + if let subject = self.subjectsRefs[variable] { + let newValue = self.value(for: variable) + subject.send(newValue) + } } } - log("Deleting all overrides.") - mutableValueProvider.deleteAll() + hasOverrides = false return variables } diff --git a/Sources/ToggleManager/ToggleManager.swift b/Sources/ToggleManager/ToggleManager.swift index 1499c69..63fa0f8 100644 --- a/Sources/ToggleManager/ToggleManager.swift +++ b/Sources/ToggleManager/ToggleManager.swift @@ -160,8 +160,11 @@ extension ToggleManager { self.cache[variable] = nil mutableValueProvider.delete(variable) DispatchQueue.main.async { - self.subjectsRefs[variable]?.send(completion: .finished) - self.subjectsRefs[variable] = nil + // Send the new default value to the subject + if let subject = self.subjectsRefs[variable] { + let newValue = self.value(for: variable) + subject.send(newValue) + } self.hasOverrides = !mutableValueProvider.variables.isEmpty } } From c0ba47ce538327b51e534ddd0a2caf3239f3a594 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 13:51:26 +0000 Subject: [PATCH 02/11] Added unit tests --- .../ToggleManager+PublishingTests.swift | 120 +++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift b/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift index 76e1d41..b27ea51 100644 --- a/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift +++ b/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift @@ -2,16 +2,21 @@ import XCTest import Combine -import Toggles +@testable import Toggles class ToggleManager_PublishingTests: XCTestCase { private var cancellables: Set = [] + private let datasourceUrl = Bundle.toggles.url(forResource: "TestDatasource", withExtension: "json")! + + override func tearDown() { + cancellables.removeAll() + super.tearDown() + } func test_publishers() throws { - let url = Bundle.toggles.url(forResource: "TestDatasource", withExtension: "json")! let inMemoryProvider = InMemoryValueProvider() - let manager = try! ToggleManager(mutableValueProvider: inMemoryProvider, datasourceUrl: url) + let manager = try ToggleManager(mutableValueProvider: inMemoryProvider, datasourceUrl: datasourceUrl) let valueExpectation = self.expectation(description: #function) let cachedPublisherValueExpectation = self.expectation(description: #function) @@ -55,4 +60,113 @@ class ToggleManager_PublishingTests: XCTestCase { wait(for: [valueExpectation, cachedPublisherValueExpectation], timeout: 5.0) } + + func test_publisherRemainsActiveAfterRemoveOverrides() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "integer_toggle" + + var receivedValues: [Value] = [] + let expectation = XCTestExpectation(description: "Receive updated values") + expectation.expectedFulfillmentCount = 3 + + manager.publisher(for: variable) + .sink { value in + receivedValues.append(value) + expectation.fulfill() + } + .store(in: &cancellables) + + manager.set(.int(999), for: variable) + manager.removeOverrides() + manager.reactToConfigurationChanges() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedValues.count, 3) + XCTAssertEqual(receivedValues[0], .int(42)) + XCTAssertEqual(receivedValues[1], .int(999)) + XCTAssertEqual(receivedValues[2], .int(42)) + } + + func test_publisherRemainsActiveAfterDelete() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "string_toggle" + + var receivedValues: [Value] = [] + let expectation = XCTestExpectation(description: "Receive updated values") + expectation.expectedFulfillmentCount = 3 + + manager.publisher(for: variable) + .sink { value in + receivedValues.append(value) + expectation.fulfill() + } + .store(in: &cancellables) + + manager.set(.string("overridden"), for: variable) + manager.delete(variable) + manager.reactToConfigurationChanges() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedValues.count, 3) + XCTAssertEqual(receivedValues[0], .string("Hello World")) + XCTAssertEqual(receivedValues[1], .string("overridden")) + XCTAssertEqual(receivedValues[2], .string("Hello World")) + } + + func test_subjectReferencesPreservedAfterRemoveOverrides() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "integer_toggle" + + _ = manager.publisher(for: variable) + XCTAssertNotNil(manager.subjectsRefs[variable]) + + manager.set(.int(999), for: variable) + manager.removeOverrides() + + XCTAssertNotNil(manager.subjectsRefs[variable], "Subject should still exist after removeOverrides()") + } + + func test_subjectReferencesPreservedAfterDelete() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "integer_toggle" + + _ = manager.publisher(for: variable) + XCTAssertNotNil(manager.subjectsRefs[variable]) + + manager.set(.int(888), for: variable) + manager.delete(variable) + + XCTAssertNotNil(manager.subjectsRefs[variable], "Subject should still exist after delete()") + } + + func test_toggleObservableRemainsActiveAfterRemoveOverrides() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "boolean_toggle" + + let observable = ToggleObservable(manager: manager, variable: variable) + + var receivedValues: [Bool?] = [] + let expectation = XCTestExpectation(description: "Receive updated bool values") + expectation.expectedFulfillmentCount = 3 + + observable.$boolValue + .sink { boolValue in + receivedValues.append(boolValue) + expectation.fulfill() + } + .store(in: &cancellables) + + manager.set(.bool(false), for: variable) + manager.removeOverrides() + manager.reactToConfigurationChanges() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedValues.count, 3) + XCTAssertEqual(receivedValues[0], true) + XCTAssertEqual(receivedValues[1], false) + XCTAssertEqual(receivedValues[2], true) + } } From e8cf3628196dbfac4c2cbb9f67f592cbc1f1ff34 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 13:54:04 +0000 Subject: [PATCH 03/11] Refactored `ToggleObservablesView` to add interactive UI elements --- .../TogglesDemo.xcodeproj/project.pbxproj | 2 + .../xcshareddata/swiftpm/Package.resolved | 14 +- .../Sources/Views/ToggleObservablesView.swift | 242 +++++++++++++++--- 3 files changed, 221 insertions(+), 37 deletions(-) diff --git a/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj b/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj index 5a89f7c..02c083a 100644 --- a/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj +++ b/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj @@ -379,6 +379,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -408,6 +409,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 528fbc1..098fc07 100644 --- a/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,24 @@ { + "originHash" : "c101c21cc202f49ddd2132f6b2405dac874253175a15e72bb116e8e24e9889c3", "pins" : [ { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } } ], - "version" : 2 + "version" : 3 } diff --git a/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift b/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift index 187907f..db89e58 100644 --- a/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift +++ b/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift @@ -6,10 +6,7 @@ import Toggles @MainActor struct ToggleObservablesView: View { - let message = """ -The values shown below are taken via ToggleObservables. -The view will show the values updating when overrides or new configurations are loaded. -""" + let manager: ToggleManager @ObservedObject var booleanObservable: ToggleObservable @ObservedObject var intObservable: ToggleObservable @@ -17,8 +14,12 @@ The view will show the values updating when overrides or new configurations are @ObservedObject var stringObservable: ToggleObservable @ObservedObject var secureObservable: ToggleObservable @ObservedObject var objectObservable: ToggleObservable + + @State private var stringValue: String = "" + @FocusState private var isStringFieldFocused: Bool init(manager: ToggleManager) { + self.manager = manager self.booleanObservable = ToggleObservable(manager: manager, variable: ToggleVariables.booleanToggle) self.intObservable = ToggleObservable(manager: manager, variable: ToggleVariables.integerToggle) self.numericObservable = ToggleObservable(manager: manager, variable: ToggleVariables.numericToggle) @@ -26,45 +27,216 @@ The view will show the values updating when overrides or new configurations are self.secureObservable = ToggleObservable(manager: manager, variable: ToggleVariables.encryptedToggle) self.objectObservable = ToggleObservable(manager: manager, variable: ToggleVariables.objectToggle) } - + var body: some View { - VStack(spacing: 10) { - Image(systemName: "slowmo") - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - Text("ToggleObservables showcase") - .font(.title) - .padding() - Text(message) - .padding() - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("\(ToggleVariables.booleanToggle):") - Text(String(booleanObservable.boolValue!)) + List { + // Header section + Section { + VStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 40)) + .foregroundStyle(.blue) + Text("Live Reactive Updates") + .font(.headline) + Text("Values update automatically when overrides or configurations change.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + // Boolean Toggle + Section { + HStack(spacing: 12) { + Image(systemName: "switch.2") + .font(.title3) + .foregroundStyle(.green) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.booleanToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(String(booleanObservable.boolValue ?? false)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + SwiftUI.Toggle("", isOn: Binding( + get: { booleanObservable.boolValue ?? false }, + set: { manager.set(.bool($0), for: ToggleVariables.booleanToggle) } + )) + .labelsHidden() + .tint(.green) + } + .padding(.vertical, 4) + } + + // Integer Toggle with Stepper + Section { + HStack(spacing: 12) { + Image(systemName: "number") + .font(.title3) + .foregroundStyle(.blue) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.integerToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(String(intObservable.intValue ?? 0)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + Stepper("", value: Binding( + get: { intObservable.intValue ?? 0 }, + set: { manager.set(.int($0), for: ToggleVariables.integerToggle) } + )) + .labelsHidden() + } + .padding(.vertical, 4) + } + + // Numeric Toggle with Stepper + Section { + HStack(spacing: 12) { + Image(systemName: "function") + .font(.title3) + .foregroundStyle(.purple) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.numericToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(String(format: "%.2f", numericObservable.numberValue ?? 0.0)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + Stepper("", value: Binding( + get: { numericObservable.numberValue ?? 0.0 }, + set: { manager.set(.number($0), for: ToggleVariables.numericToggle) } + ), step: 0.5) + .labelsHidden() } - HStack { - Text("\(ToggleVariables.integerToggle):") - Text(String(intObservable.intValue!)) + .padding(.vertical, 4) + } + + // String Toggle with TextField + Section { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "textformat") + .font(.title3) + .foregroundStyle(.orange) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.stringToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(stringObservable.stringValue ?? "") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + + HStack { + TextField("Enter value", text: $stringValue) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .focused($isStringFieldFocused) + .onSubmit { + manager.set(.string(stringValue), for: ToggleVariables.stringToggle) + } +#if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() +#endif + + Button { + manager.set(.string(stringValue), for: ToggleVariables.stringToggle) + isStringFieldFocused = false + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } } - HStack { - Text("\(ToggleVariables.numericToggle):") - Text(String(numericObservable.numberValue!)) + .padding(.vertical, 4) + .onAppear { + stringValue = stringObservable.stringValue ?? "" } - HStack { - Text("\(ToggleVariables.stringToggle):") - Text(stringObservable.stringValue!) + .onChange(of: stringObservable.stringValue) { _, newValue in + if !isStringFieldFocused { + stringValue = newValue ?? "" + } } - HStack { - Text("\(ToggleVariables.encryptedToggle):") - Text(secureObservable.secureValue!) + } + + // Read-only toggles + Section { + HStack(spacing: 12) { + Image(systemName: "lock.fill") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.encryptedToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(secureObservable.secureValue ?? "") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() } - HStack { - Text("\(ToggleVariables.objectToggle):") - Text(objectObservable.objectValue?.description ?? "unknown") + .padding(.vertical, 4) + + HStack(spacing: 12) { + Image(systemName: "curlybraces") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.objectToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(objectObservable.objectValue?.description ?? "unknown") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + Spacer() } - }.padding(.horizontal, 24) + .padding(.vertical, 4) + } header: { + Text("Read-only") + } } +#if os(iOS) + .listStyle(.insetGrouped) +#endif + .navigationTitle("Observables") } } From bbb2056a5f66a02cc752bae73701fab8da7f296e Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 14:37:07 +0000 Subject: [PATCH 04/11] Added `isOverriden` internal method --- .../ToggleManager+Overrides.swift | 10 ++++ .../ToggleManager+OverridesTests.swift | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/Sources/ToggleManager/ToggleManager+Overrides.swift b/Sources/ToggleManager/ToggleManager+Overrides.swift index 3e8fcc6..e7993f8 100644 --- a/Sources/ToggleManager/ToggleManager+Overrides.swift +++ b/Sources/ToggleManager/ToggleManager+Overrides.swift @@ -12,6 +12,16 @@ extension ToggleManager { } } + /// Returns whether the given variable has an active override. + /// - Parameter variable: The variable to check. + /// - Returns: `true` if the variable is overridden, `false` otherwise. + func isOverridden(_ variable: Variable) -> Bool { + queue.sync { + guard let mutableValueProvider = mutableValueProvider else { return false } + return mutableValueProvider.value(for: variable) != nil + } + } + /// Removes all overridden toggles in the mutable value provider, if one was provided during initialization. @discardableResult public func removeOverrides() -> Set { diff --git a/Tests/Suites/ToggleManager/ToggleManager+OverridesTests.swift b/Tests/Suites/ToggleManager/ToggleManager+OverridesTests.swift index e77c201..b6d6933 100644 --- a/Tests/Suites/ToggleManager/ToggleManager+OverridesTests.swift +++ b/Tests/Suites/ToggleManager/ToggleManager+OverridesTests.swift @@ -42,4 +42,57 @@ final class ToggleManager_OverridesTests: XCTestCase { toggleManager.removeOverrides() XCTAssertNotNil(toggleManager.cache.value(forKey: "string_toggle")) } + + // MARK: - isOverridden method tests + + func test_isOverridden_returnsFalse_whenNoMutableValueProvider() throws { + let toggleManager = try ToggleManager(datasourceUrl: url) + XCTAssertFalse(toggleManager.isOverridden("integer_toggle")) + } + + func test_isOverridden_returnsFalse_whenVariableHasNoOverride() throws { + let toggleManager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: url) + XCTAssertFalse(toggleManager.isOverridden("integer_toggle")) + } + + func test_isOverridden_returnsTrue_whenVariableHasOverride() throws { + let variable = "integer_toggle" + let toggleManager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: url) + toggleManager.set(.int(999), for: variable) + XCTAssertTrue(toggleManager.isOverridden(variable)) + } + + func test_isOverridden_returnsFalse_afterDelete() throws { + let variable = "integer_toggle" + let toggleManager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: url) + toggleManager.set(.int(999), for: variable) + XCTAssertTrue(toggleManager.isOverridden(variable)) + + toggleManager.delete(variable) + XCTAssertFalse(toggleManager.isOverridden(variable)) + } + + func test_isOverridden_returnsFalse_afterRemoveOverrides() throws { + let variable = "integer_toggle" + let toggleManager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: url) + toggleManager.set(.int(999), for: variable) + XCTAssertTrue(toggleManager.isOverridden(variable)) + + toggleManager.removeOverrides() + XCTAssertFalse(toggleManager.isOverridden(variable)) + } + + func test_isOverridden_onlyAffectsSpecificVariable() throws { + let toggleManager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: url) + toggleManager.set(.int(999), for: "integer_toggle") + + XCTAssertTrue(toggleManager.isOverridden("integer_toggle")) + XCTAssertFalse(toggleManager.isOverridden("string_toggle")) + XCTAssertFalse(toggleManager.isOverridden("boolean_toggle")) + } } From 53990a49fd84a320e12d5ecc8fe8eebb2f5a199b Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 14:37:33 +0000 Subject: [PATCH 05/11] Added `isObjectToggle` to`InputValidationHelper` --- Sources/Utilities/InputValidationHelper.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Utilities/InputValidationHelper.swift b/Sources/Utilities/InputValidationHelper.swift index 63610eb..064087b 100644 --- a/Sources/Utilities/InputValidationHelper.swift +++ b/Sources/Utilities/InputValidationHelper.swift @@ -70,6 +70,13 @@ struct InputValidationHelper { return false } + var isObjectToggle: Bool { + if case .object = toggle.value { + return true + } + return false + } + var toggleNeedsValidation: Bool { if case .bool = toggle.value { return false } if case .string = toggle.value { return false } From 1c3513d99a05fb8afb851738d46c68e25d9815eb Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 14:37:55 +0000 Subject: [PATCH 06/11] Updated symbols for values --- Sources/Extensions/Value+Utilities.swift | 6 +++--- Tests/Suites/Extensions/Value+UtilitiesTests.swift | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/Extensions/Value+Utilities.swift b/Sources/Extensions/Value+Utilities.swift index 21379c2..c50f54c 100644 --- a/Sources/Extensions/Value+Utilities.swift +++ b/Sources/Extensions/Value+Utilities.swift @@ -47,13 +47,13 @@ extension Value { case .int: return "number.square" case .number: - return "number.square.fill" + return "function" case .string: return "textformat" case .secure: - return "eye.slash" + return "lock.fill" case .object: - return "aqi.medium" + return "curlybraces" } } } diff --git a/Tests/Suites/Extensions/Value+UtilitiesTests.swift b/Tests/Suites/Extensions/Value+UtilitiesTests.swift index e3d32a3..a6d6baa 100644 --- a/Tests/Suites/Extensions/Value+UtilitiesTests.swift +++ b/Tests/Suites/Extensions/Value+UtilitiesTests.swift @@ -54,26 +54,26 @@ final class Value_UtilitiesTests: XCTestCase { } func test_booleanValueSFSybol() throws { - XCTAssertEqual(Value.bool(true).sfSymbolId, "switch.2") + XCTAssertEqual(Value.bool(true).sfSymbolId, "power") } func test_intValueSFSybol() throws { - XCTAssertEqual(Value.int(42).sfSymbolId, "number.square") + XCTAssertEqual(Value.int(42).sfSymbolId, "number") } func test_numberValueSFSybol() throws { - XCTAssertEqual(Value.number(3.1416).sfSymbolId, "number.square.fill") + XCTAssertEqual(Value.number(3.1416).sfSymbolId, "function") } func test_stringValueSFSybol() throws { - XCTAssertEqual(Value.string("Hello World").sfSymbolId, "textformat") + XCTAssertEqual(Value.string("Hello World").sfSymbolId, "textformat.abc") } func test_secureValueSFSybol() throws { - XCTAssertEqual(Value.secure("secret").sfSymbolId, "eye.slash") + XCTAssertEqual(Value.secure("secret").sfSymbolId, "lock.fill") } func test_ObjectValueSFSybol() throws { - XCTAssertEqual(Value.object(Object(map: [:])).sfSymbolId, "aqi.medium") + XCTAssertEqual(Value.object(Object(map: [:])).sfSymbolId, "curlybraces") } } From 2bd65ed0e3ddb6e9ba1c321c5babe309a6f95bb6 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 14:39:24 +0000 Subject: [PATCH 07/11] Updated `ToggleView` with boolean shortcuts and overridden values highlighting --- Sources/Views/TogglesView.swift | 98 ++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/Sources/Views/TogglesView.swift b/Sources/Views/TogglesView.swift index 7495f0a..5bb7a25 100644 --- a/Sources/Views/TogglesView.swift +++ b/Sources/Views/TogglesView.swift @@ -5,33 +5,85 @@ public import SwiftUI /// A view showcasing all toggles from a provided datasource. public struct TogglesView: View { + // MARK: - Unified Toggle Row + private struct ToggleRow: View { + let manager: ToggleManager + let toggle: Toggle + let canOverride: Bool - private var toggle: Toggle - @ObservedObject var toggleObservable: ToggleObservable - init(manager: ToggleManager, toggle: Toggle) { + private var isOverridden: Bool { + manager.isOverridden(toggle.variable) + } + + private var isBooleanToggle: Bool { + if case .bool = toggle.value { return true } + return false + } + + init(manager: ToggleManager, toggle: Toggle, canOverride: Bool) { + self.manager = manager self.toggle = toggle + self.canOverride = canOverride self.toggleObservable = ToggleObservable(manager: manager, variable: toggle.variable) } var body: some View { - HStack(alignment: .center) { + HStack(alignment: .center, spacing: 12) { + // Type icon Image(systemName: toggle.value.sfSymbolId) - .padding(.trailing, 5.0) - VStack(alignment: .leading) { + .font(.title3) + .foregroundStyle(isOverridden ? .orange : .secondary) + .frame(width: 28) + + // Toggle info + VStack(alignment: .leading, spacing: 2) { Text(toggle.metadata.description) - .bold() - .multilineTextAlignment(.leading) - Text(toggle.variable) - .multilineTextAlignment(.leading) + .font(.body) + .fontWeight(.medium) + + HStack(spacing: 4) { + Text(toggle.variable) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if isOverridden { + Image(systemName: "pencil.circle.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } } - .padding([.all], 5.0) + Spacer() - Text(toggleObservable.value.description) - .multilineTextAlignment(.trailing) + + // Value display / control + if isBooleanToggle && canOverride { + SwiftUI.Toggle("", isOn: Binding( + get: { toggleObservable.boolValue ?? false }, + set: { manager.set(.bool($0), for: toggle.variable) } + )) + .labelsHidden() + .tint(.green) + } else { + VStack(alignment: .trailing, spacing: 2) { + Text(toggleObservable.value.description) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(isOverridden ? .orange : .primary) + + if isOverridden && !isBooleanToggle { + Text("overridden") + .font(.caption2) + .foregroundStyle(.orange) + } + } + } } + .padding(.vertical, 4) } } @@ -43,9 +95,13 @@ public struct TogglesView: View { @State private var showingOptions = false @State private var presentDeleteAlert = false @State private var overriddenVariables: Set = [] - + + private var canOverride: Bool { + manager.mutableValueProvider != nil + } + /// The default initializer for the view. - /// + /// /// - Parameters: /// - manager: The manager used to retrieve and update the toggles. The manager should be setup using the same datasource provded to this view. /// - datasourceUul: The url to the datasource. @@ -55,13 +111,13 @@ public struct TogglesView: View { let groups = try! GroupLoader.loadGroups(datasourceUrl: datasourceUrl) self._groups = State(initialValue: groups) } - + public var body: some View { List { ForEach(searchResults) { group in Section(header: Text(group.title)) { ForEach(group.toggles) { toggle in - navigationLink(toggle: toggle) + toggleRowView(for: toggle) } } .accessibilityLabel(group.accessibilityLabel) @@ -90,11 +146,11 @@ public struct TogglesView: View { } } - private func navigationLink(toggle: Toggle) -> some View { + private func toggleRowView(for toggle: Toggle) -> some View { NavigationLink { ToggleDetailView(manager: manager, toggle: toggle) } label: { - ToggleRow(manager: manager, toggle: toggle) + ToggleRow(manager: manager, toggle: toggle, canOverride: canOverride) } .accessibilityLabel(toggle.accessibilityLabel) } @@ -129,6 +185,8 @@ struct TogglesView_Previews: PreviewProvider { let mutableValueProvider = PersistentValueProvider(userDefaults: .standard) let manager = try! ToggleManager(mutableValueProvider: mutableValueProvider, datasourceUrl: datasourceUrl) - return TogglesView(manager: manager, datasourceUrl: datasourceUrl) + NavigationView { + TogglesView(manager: manager, datasourceUrl: datasourceUrl) + } } } From 22f28fcf45e7e6f27a5537e860e01ba608ed62c8 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 14:40:04 +0000 Subject: [PATCH 08/11] Redesigned `ToggleDetailView` --- Sources/Views/ToggleDetailView.swift | 429 ++++++++++++++++++++------- 1 file changed, 326 insertions(+), 103 deletions(-) diff --git a/Sources/Views/ToggleDetailView.swift b/Sources/Views/ToggleDetailView.swift index fdcdd5d..68466d7 100644 --- a/Sources/Views/ToggleDetailView.swift +++ b/Sources/Views/ToggleDetailView.swift @@ -14,11 +14,16 @@ struct ToggleDetailView: View { @State private var boolValue: Bool = false @State private var textValue: String = "" @State private var isValidInput: Bool = false - @State private var refresh: Bool = false @State private var valueOverridden: Bool = false - + @State private var showingResetConfirmation: Bool = false + @State private var expandedValue: String? = nil + @ObservedObject var toggleObservable: ToggleObservable + private var isOverridden: Bool { + manager.isOverridden(toggle.variable) + } + init(manager: ToggleManager, toggle: Toggle) { self.manager = manager self.toggle = toggle @@ -27,162 +32,378 @@ struct ToggleDetailView: View { } var body: some View { - listView - } - - private var listView: some View { List { - toggleInformationSection - currentValueSection - // cacheSection - providersSection if manager.mutableValueProvider != nil { - overrideValueSection + overrideSection } + currentValueSection + providersSection + metadataSection } +#if os(iOS) + .listStyle(.insetGrouped) + .navigationBarTitleDisplayMode(.inline) +#endif .navigationTitle(toggle.metadata.description) + .sheet(item: $expandedValue) { value in + ExpandedValueView(value: value) + } .onAppear { - if case .bool(let value) = manager.value(for: toggle.variable) { - boolValue = value - } - textValue = manager.value(for: toggle.variable).description + syncValues() } .onChange(of: textValue) { newValue in isValidInput = inputValidationHelper.isInputValid(newValue) } - .onChange(of: refresh) { _ in } + .onChange(of: toggleObservable.value) { _ in + syncValues() + } + .confirmationDialog( + "Reset to Default", + isPresented: $showingResetConfirmation, + titleVisibility: .visible + ) { + Button("Reset", role: .destructive) { + manager.delete(toggle.variable) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will remove the override and restore the default value.") + } } - private var toggleInformationSection: some View { - Section(header: Text("Information")) { - HStack { - Text("Variable") - Spacer() - Text(toggle.variable) + private func syncValues() { + if case .bool(let value) = manager.value(for: toggle.variable) { + boolValue = value + } + textValue = manager.value(for: toggle.variable).description + } + + // MARK: - Override Section (Top Priority) + + private var overrideSection: some View { + Section { + VStack(spacing: 16) { + // Current value display + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Current Value") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(toggleObservable.value.description) + .font(.system(.title2, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(isOverridden ? .orange : .primary) + } + Spacer() + } + .padding(.vertical, 4) + + Divider() + + // Override control + if inputValidationHelper.isBooleanToggle { + booleanOverrideControl + } else { + textOverrideControl + } } - HStack { - Text("Value type") - Spacer() - Text(toggle.value.typeDescription) + .padding(.vertical, 8) + + // Reset button as a proper list row + if isOverridden { + Button(role: .destructive) { + showingResetConfirmation = true + } label: { + HStack { + Image(systemName: "arrow.counterclockwise") + .foregroundStyle(.red) + Text("Reset to Default") + } + } } + } header: { HStack { - Text("Group") - Spacer() - Text(toggle.metadata.group) + Label("Override", systemImage: "slider.horizontal.3") + if isOverridden { + Spacer() + Text("Active") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.orange) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.orange.opacity(0.15)) + .clipShape(Capsule()) + } + } + } + } + + private var booleanOverrideControl: some View { + HStack { + Text("Override to") + .foregroundStyle(.secondary) + + Spacer() + + HStack(spacing: 12) { + Button { + manager.set(.bool(false), for: toggle.variable) + boolValue = false + showOverrideFeedback() + } label: { + Text("false") + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(!boolValue && isOverridden ? .white : .red) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(!boolValue && isOverridden ? Color.red : Color.red.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Button { + manager.set(.bool(true), for: toggle.variable) + boolValue = true + showOverrideFeedback() + } label: { + Text("true") + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(boolValue && isOverridden ? .white : .green) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(boolValue && isOverridden ? Color.green : Color.green.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } + + private var textOverrideControl: some View { + VStack(spacing: 12) { + HStack(alignment: inputValidationHelper.isObjectToggle ? .top : .center) { + // Use TextEditor for multiline content (objects, long strings) + if inputValidationHelper.isObjectToggle { + TextEditor(text: $textValue) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 80, maxHeight: 120) + .padding(4) +#if os(iOS) + .background(Color(.systemGray5)) +#else + .background(Color.gray.opacity(0.2)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + TextField("Enter value", text: $textValue) + .font(.system(.body, design: .monospaced)) +#if os(iOS) + .keyboardType(inputValidationHelper.keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() +#endif + .onSubmit { + submitOverride() + } + .submitLabel(.done) + .padding(12) +#if os(iOS) + .background(Color(.systemGray5)) +#else + .background(Color.gray.opacity(0.2)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + Button { + submitOverride() + } label: { + Image(systemName: valueOverridden ? "checkmark.circle.fill" : "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(valueOverridden ? .green : (isValidInput ? .blue : .gray)) + } + .disabled(!isValidInput) + .buttonStyle(.plain) + } + + if inputValidationHelper.toggleNeedsValidation && !textValue.isEmpty { + HStack { + if isValidInput { + Label("Valid \(toggle.value.typeDescription.lowercased())", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Label("Invalid \(toggle.value.typeDescription.lowercased())", systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + } + Spacer() + } } } } + private func submitOverride() { + guard isValidInput else { return } + manager.set(inputValidationHelper.overridingValue(for: textValue), for: toggle.variable) + showOverrideFeedback() +#if os(iOS) + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) +#endif + } + + private func showOverrideFeedback() { + valueOverridden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + valueOverridden = false + } + } + + // MARK: - Current Value Section + private var currentValueSection: some View { - Section(header: Text("Current returned value")) { + Section { HStack { - Text("Via the getter") + Label("Via getter", systemImage: "arrow.right.circle") Spacer() Text(manager.value(for: toggle.variable).description) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .contentShape(Rectangle()) + .onTapGesture { + expandedValue = manager.value(for: toggle.variable).description } + HStack { - Text("Via the publisher") + Label("Via publisher", systemImage: "antenna.radiowaves.left.and.right") Spacer() Text(toggleObservable.value.description) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) } - } - } - - private var cacheSection: some View { - Section(header: Text("Cached value")) { - HStack { - Text("Cache") - Spacer() - if let value = manager.getCachedValue(for: toggle.variable) { - Text(value.description) - .font(.body) - } else { - Text("nil") - .font(.body) - .italic() - } + .contentShape(Rectangle()) + .onTapGesture { + expandedValue = toggleObservable.value.description } + } header: { + Label("Live Value", systemImage: "bolt.fill") } } + // MARK: - Providers Section + private var providersSection: some View { - Section(header: Text("Providers"), - footer: Text("The providers are listed in priority order.")) { + Section { ForEach(manager.stackTrace(for: toggle.variable)) { trace in HStack { - Text(trace.providerName) + Label(trace.providerName, systemImage: "shippingbox") + .foregroundStyle(trace.value != nil ? .primary : .tertiary) Spacer() if let value = trace.value { Text(value.description) - .font(.body) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) } else { - Text("nil") - .font(.body) - .italic() + Text("—") + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if let value = trace.value { + expandedValue = value.description } } } + } header: { + Label("Provider Stack", systemImage: "square.stack.3d.up") + } footer: { + Text("Values are resolved from top to bottom. First non-nil value wins.") } } - private var overrideValueSection: some View { + // MARK: - Metadata Section + + private var metadataSection: some View { Section { HStack { - if inputValidationHelper.isBooleanToggle { - SwiftUI.Toggle(isOn: $boolValue) { - EmptyView() - } - .frame(width: 1, height: 1, alignment: .leading) - .onChange(of: boolValue) { newValue in - textValue = newValue ? "t" : "f" - } - } - else { - TextField("Override value", text: $textValue) -#if os(iOS) - .keyboardType(inputValidationHelper.keyboardType) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() -#endif - } + Label("Variable", systemImage: "tag") Spacer() - overrideButtonView + Text(toggle.variable) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.secondary) } - } header: { - Text("Override value") - } footer: { HStack { - if inputValidationHelper.toggleNeedsValidation { - if isValidInput { - Label("Valid input", systemImage: "checkmark.diamond") - .font(.caption) - .foregroundColor(.green) - } - else { - Label("Invalid input", systemImage: "multiply.circle") - .font(.caption) - .foregroundColor(.red) - } - } - if valueOverridden { - Spacer() - Label("Value overridden", systemImage: "checkmark") - .font(.caption) - } + Label("Type", systemImage: toggle.value.sfSymbolId) + Spacer() + Text(toggle.value.typeDescription) + .foregroundStyle(.secondary) + } + HStack { + Label("Group", systemImage: "folder") + Spacer() + Text(toggle.metadata.group) + .foregroundStyle(.secondary) } + } header: { + Label("Metadata", systemImage: "info.circle") } } +} + +// MARK: - Expanded Value View + +extension String: @retroactive Identifiable { + public var id: String { self } +} + +private struct ExpandedValueView: View { + let value: String + @Environment(\.dismiss) private var dismiss - private var overrideButtonView: some View { - Button("Override") { - manager.set(inputValidationHelper.overridingValue(for: textValue), for: toggle.variable) - valueOverridden = true - refresh.toggle() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - valueOverridden = false + var body: some View { + NavigationView { + ScrollView { + Text(value) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle("Value") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + UIPasteboard.general.string = value + } label: { + Image(systemName: "doc.on.doc") + } + } } +#else + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } +#endif } - .disabled(!isValidInput) } } @@ -196,6 +417,8 @@ struct ToggleDetailView_Previews: PreviewProvider { datasourceUrl: datasourceUrl) let content = try! Data(contentsOf: datasourceUrl) let datasource = try! JSONDecoder().decode(Datasource.self, from: content) - ToggleDetailView(manager: manager, toggle: datasource.toggles[0]) + NavigationView { + ToggleDetailView(manager: manager, toggle: datasource.toggles[0]) + } } } From 472e51686b9d340e2f4dba27eb26558b12e9dc8a Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 15:02:09 +0000 Subject: [PATCH 09/11] Split `TogglesView` and added previews --- Sources/Views/TogglesView.swift | 192 -------------------- Sources/Views/TogglesView/ToggleRow.swift | 113 ++++++++++++ Sources/Views/TogglesView/TogglesView.swift | 108 +++++++++++ 3 files changed, 221 insertions(+), 192 deletions(-) delete mode 100644 Sources/Views/TogglesView.swift create mode 100644 Sources/Views/TogglesView/ToggleRow.swift create mode 100644 Sources/Views/TogglesView/TogglesView.swift diff --git a/Sources/Views/TogglesView.swift b/Sources/Views/TogglesView.swift deleted file mode 100644 index 5bb7a25..0000000 --- a/Sources/Views/TogglesView.swift +++ /dev/null @@ -1,192 +0,0 @@ -// TogglesView.swift - -public import SwiftUI - -/// A view showcasing all toggles from a provided datasource. -public struct TogglesView: View { - - // MARK: - Unified Toggle Row - - private struct ToggleRow: View { - let manager: ToggleManager - let toggle: Toggle - let canOverride: Bool - - @ObservedObject var toggleObservable: ToggleObservable - - private var isOverridden: Bool { - manager.isOverridden(toggle.variable) - } - - private var isBooleanToggle: Bool { - if case .bool = toggle.value { return true } - return false - } - - init(manager: ToggleManager, toggle: Toggle, canOverride: Bool) { - self.manager = manager - self.toggle = toggle - self.canOverride = canOverride - self.toggleObservable = ToggleObservable(manager: manager, variable: toggle.variable) - } - - var body: some View { - HStack(alignment: .center, spacing: 12) { - // Type icon - Image(systemName: toggle.value.sfSymbolId) - .font(.title3) - .foregroundStyle(isOverridden ? .orange : .secondary) - .frame(width: 28) - - // Toggle info - VStack(alignment: .leading, spacing: 2) { - Text(toggle.metadata.description) - .font(.body) - .fontWeight(.medium) - - HStack(spacing: 4) { - Text(toggle.variable) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - - if isOverridden { - Image(systemName: "pencil.circle.fill") - .font(.caption2) - .foregroundStyle(.orange) - } - } - } - - Spacer() - - // Value display / control - if isBooleanToggle && canOverride { - SwiftUI.Toggle("", isOn: Binding( - get: { toggleObservable.boolValue ?? false }, - set: { manager.set(.bool($0), for: toggle.variable) } - )) - .labelsHidden() - .tint(.green) - } else { - VStack(alignment: .trailing, spacing: 2) { - Text(toggleObservable.value.description) - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.medium) - .foregroundStyle(isOverridden ? .orange : .primary) - - if isOverridden && !isBooleanToggle { - Text("overridden") - .font(.caption2) - .foregroundStyle(.orange) - } - } - } - } - .padding(.vertical, 4) - } - } - - @ObservedObject public var manager: ToggleManager - public let datasourceUrl: URL - - @State private var searchText = "" - @State private var groups: [Group] = [] - @State private var showingOptions = false - @State private var presentDeleteAlert = false - @State private var overriddenVariables: Set = [] - - private var canOverride: Bool { - manager.mutableValueProvider != nil - } - - /// The default initializer for the view. - /// - /// - Parameters: - /// - manager: The manager used to retrieve and update the toggles. The manager should be setup using the same datasource provded to this view. - /// - datasourceUul: The url to the datasource. - public init(manager: ToggleManager, datasourceUrl: URL) { - self.manager = manager - self.datasourceUrl = datasourceUrl - let groups = try! GroupLoader.loadGroups(datasourceUrl: datasourceUrl) - self._groups = State(initialValue: groups) - } - - public var body: some View { - List { - ForEach(searchResults) { group in - Section(header: Text(group.title)) { - ForEach(group.toggles) { toggle in - toggleRowView(for: toggle) - } - } - .accessibilityLabel(group.accessibilityLabel) - } - } -#if os(iOS) - .listStyle(.insetGrouped) -#endif - .accessibilityLabel("Toggles list") - .navigationTitle("Toggles") - .toolbar { - if manager.hasOverrides { - toolbarView - } - } - .searchable(text: $searchText, prompt: "Filter toggles") -#if os(iOS) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() -#endif - .alert("Cleared overrides", isPresented: $presentDeleteAlert) { - Button("OK!", role: .cancel) {} - } message: { - let variables = overriddenVariables.joined(separator: "\n") - Text("The overrides for the following variables have been deleted:\n\n\(variables)") - } - } - - private func toggleRowView(for toggle: Toggle) -> some View { - NavigationLink { - ToggleDetailView(manager: manager, toggle: toggle) - } label: { - ToggleRow(manager: manager, toggle: toggle, canOverride: canOverride) - } - .accessibilityLabel(toggle.accessibilityLabel) - } - - private var toolbarView: some View { - Button { - showingOptions = true - } label: { - Image(systemName: "ellipsis.circle") - } - .confirmationDialog("Select an action", isPresented: $showingOptions) { - Button("Clear overrides") { - Task { - await MainActor.run { - overriddenVariables = manager.removeOverrides() - presentDeleteAlert = true - } - } - } - } - } - - private var searchResults: [Group] { - SearchFilter(groups: groups) - .searchResults(for: searchText) - } -} - -struct TogglesView_Previews: PreviewProvider { - static var previews: some View { - let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! - let mutableValueProvider = PersistentValueProvider(userDefaults: .standard) - let manager = try! ToggleManager(mutableValueProvider: mutableValueProvider, - datasourceUrl: datasourceUrl) - NavigationView { - TogglesView(manager: manager, datasourceUrl: datasourceUrl) - } - } -} diff --git a/Sources/Views/TogglesView/ToggleRow.swift b/Sources/Views/TogglesView/ToggleRow.swift new file mode 100644 index 0000000..5a46adb --- /dev/null +++ b/Sources/Views/TogglesView/ToggleRow.swift @@ -0,0 +1,113 @@ +// ToggleRow.swift + +import SwiftUI + +struct ToggleRow: View { + let manager: ToggleManager + let toggle: Toggle + let canOverride: Bool + + @ObservedObject var toggleObservable: ToggleObservable + + private var isOverridden: Bool { + manager.isOverridden(toggle.variable) + } + + private var isBooleanToggle: Bool { + if case .bool = toggle.value { return true } + return false + } + + init(manager: ToggleManager, toggle: Toggle, canOverride: Bool) { + self.manager = manager + self.toggle = toggle + self.canOverride = canOverride + self.toggleObservable = ToggleObservable(manager: manager, variable: toggle.variable) + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + // Type icon + Image(systemName: toggle.value.sfSymbolId) + .font(.title3) + .foregroundStyle(isOverridden ? .orange : .secondary) + .frame(width: 28) + + // Toggle info + VStack(alignment: .leading, spacing: 2) { + Text(toggle.metadata.description) + .font(.body) + .fontWeight(.medium) + + HStack(spacing: 4) { + Text(toggle.variable) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if isOverridden { + Image(systemName: "pencil.circle.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } + } + + Spacer() + + // Value display / control + if isBooleanToggle && canOverride { + SwiftUI.Toggle("", isOn: Binding( + get: { toggleObservable.boolValue ?? false }, + set: { manager.set(.bool($0), for: toggle.variable) } + )) + .labelsHidden() + .tint(.green) + } else { + VStack(alignment: .trailing, spacing: 2) { + Text(toggleObservable.value.description) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(isOverridden ? .orange : .primary) + + if isOverridden && !isBooleanToggle { + Text("overridden") + .font(.caption2) + .foregroundStyle(.orange) + } + } + } + } + .padding(.vertical, 4) + } +} + +#Preview("ToggleRow - Boolean") { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let boolToggle = datasource.toggles.first { toggle in + if case .bool = toggle.value { return true } + return false + }! + List { + ToggleRow(manager: manager, toggle: boolToggle, canOverride: true) + } +} + +#Preview("ToggleRow - String") { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let stringToggle = datasource.toggles.first { toggle in + if case .string = toggle.value { return true } + return false + }! + List { + ToggleRow(manager: manager, toggle: stringToggle, canOverride: true) + } +} diff --git a/Sources/Views/TogglesView/TogglesView.swift b/Sources/Views/TogglesView/TogglesView.swift new file mode 100644 index 0000000..b3d6d15 --- /dev/null +++ b/Sources/Views/TogglesView/TogglesView.swift @@ -0,0 +1,108 @@ +// TogglesView.swift + +public import SwiftUI + +/// A view showcasing all toggles from a provided datasource. +public struct TogglesView: View { + + @ObservedObject public var manager: ToggleManager + public let datasourceUrl: URL + + @State private var searchText = "" + @State private var groups: [Group] = [] + @State private var showingOptions = false + @State private var presentDeleteAlert = false + @State private var overriddenVariables: Set = [] + + private var canOverride: Bool { + manager.mutableValueProvider != nil + } + + /// The default initializer for the view. + /// + /// - Parameters: + /// - manager: The manager used to retrieve and update the toggles. The manager should be setup using the same datasource provded to this view. + /// - datasourceUul: The url to the datasource. + public init(manager: ToggleManager, datasourceUrl: URL) { + self.manager = manager + self.datasourceUrl = datasourceUrl + let groups = try! GroupLoader.loadGroups(datasourceUrl: datasourceUrl) + self._groups = State(initialValue: groups) + } + + public var body: some View { + List { + ForEach(searchResults) { group in + Section(header: Text(group.title)) { + ForEach(group.toggles) { toggle in + toggleRowView(for: toggle) + } + } + .accessibilityLabel(group.accessibilityLabel) + } + } +#if os(iOS) + .listStyle(.insetGrouped) +#endif + .accessibilityLabel("Toggles list") + .navigationTitle("Toggles") + .toolbar { + if manager.hasOverrides { + toolbarView + } + } + .searchable(text: $searchText, prompt: "Filter toggles") +#if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() +#endif + .alert("Cleared overrides", isPresented: $presentDeleteAlert) { + Button("OK!", role: .cancel) {} + } message: { + let variables = overriddenVariables.joined(separator: "\n") + Text("The overrides for the following variables have been deleted:\n\n\(variables)") + } + } + + private func toggleRowView(for toggle: Toggle) -> some View { + NavigationLink { + ToggleDetailView(manager: manager, toggle: toggle) + } label: { + ToggleRow(manager: manager, toggle: toggle, canOverride: canOverride) + } + .accessibilityLabel(toggle.accessibilityLabel) + } + + private var toolbarView: some View { + Button { + showingOptions = true + } label: { + Image(systemName: "ellipsis.circle") + } + .confirmationDialog("Select an action", isPresented: $showingOptions) { + Button("Clear overrides") { + Task { + await MainActor.run { + overriddenVariables = manager.removeOverrides() + presentDeleteAlert = true + } + } + } + } + } + + private var searchResults: [Group] { + SearchFilter(groups: groups) + .searchResults(for: searchText) + } +} + +#Preview { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let mutableValueProvider = PersistentValueProvider(userDefaults: .standard) + let manager = try! ToggleManager(mutableValueProvider: mutableValueProvider, + datasourceUrl: datasourceUrl) + NavigationView { + TogglesView(manager: manager, datasourceUrl: datasourceUrl) + } +} From 56f4662b225a31a9e3f9bb24531460f53748c4e5 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 15:02:33 +0000 Subject: [PATCH 10/11] Split `ToggleDetailView` into smaller components and added previews --- Sources/Views/ToggleDetailView.swift | 424 ------------------ .../BooleanOverrideControl.swift | 94 ++++ .../ToggleDetailView/ExpandedValueView.swift | 98 ++++ .../ToggleDetailView/LiveValueSection.swift | 67 +++ .../ToggleDetailView/MetadataSection.swift | 42 ++ .../ToggleDetailView/OverrideSection.swift | 169 +++++++ .../ProviderStackSection.swift | 65 +++ .../TextOverrideControl.swift | 160 +++++++ .../ToggleDetailView/ToggleDetailView.swift | 109 +++++ 9 files changed, 804 insertions(+), 424 deletions(-) delete mode 100644 Sources/Views/ToggleDetailView.swift create mode 100644 Sources/Views/ToggleDetailView/BooleanOverrideControl.swift create mode 100644 Sources/Views/ToggleDetailView/ExpandedValueView.swift create mode 100644 Sources/Views/ToggleDetailView/LiveValueSection.swift create mode 100644 Sources/Views/ToggleDetailView/MetadataSection.swift create mode 100644 Sources/Views/ToggleDetailView/OverrideSection.swift create mode 100644 Sources/Views/ToggleDetailView/ProviderStackSection.swift create mode 100644 Sources/Views/ToggleDetailView/TextOverrideControl.swift create mode 100644 Sources/Views/ToggleDetailView/ToggleDetailView.swift diff --git a/Sources/Views/ToggleDetailView.swift b/Sources/Views/ToggleDetailView.swift deleted file mode 100644 index 68466d7..0000000 --- a/Sources/Views/ToggleDetailView.swift +++ /dev/null @@ -1,424 +0,0 @@ -// ToggleDetailView.swift - -import SwiftUI -#if os(iOS) -import UIKit -#endif - -struct ToggleDetailView: View { - - let manager: ToggleManager - let toggle: Toggle - let inputValidationHelper: InputValidationHelper - - @State private var boolValue: Bool = false - @State private var textValue: String = "" - @State private var isValidInput: Bool = false - @State private var valueOverridden: Bool = false - @State private var showingResetConfirmation: Bool = false - @State private var expandedValue: String? = nil - - @ObservedObject var toggleObservable: ToggleObservable - - private var isOverridden: Bool { - manager.isOverridden(toggle.variable) - } - - init(manager: ToggleManager, toggle: Toggle) { - self.manager = manager - self.toggle = toggle - self.toggleObservable = ToggleObservable(manager: manager, variable: toggle.variable) - self.inputValidationHelper = InputValidationHelper(toggle: toggle) - } - - var body: some View { - List { - if manager.mutableValueProvider != nil { - overrideSection - } - currentValueSection - providersSection - metadataSection - } -#if os(iOS) - .listStyle(.insetGrouped) - .navigationBarTitleDisplayMode(.inline) -#endif - .navigationTitle(toggle.metadata.description) - .sheet(item: $expandedValue) { value in - ExpandedValueView(value: value) - } - .onAppear { - syncValues() - } - .onChange(of: textValue) { newValue in - isValidInput = inputValidationHelper.isInputValid(newValue) - } - .onChange(of: toggleObservable.value) { _ in - syncValues() - } - .confirmationDialog( - "Reset to Default", - isPresented: $showingResetConfirmation, - titleVisibility: .visible - ) { - Button("Reset", role: .destructive) { - manager.delete(toggle.variable) - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will remove the override and restore the default value.") - } - } - - private func syncValues() { - if case .bool(let value) = manager.value(for: toggle.variable) { - boolValue = value - } - textValue = manager.value(for: toggle.variable).description - } - - // MARK: - Override Section (Top Priority) - - private var overrideSection: some View { - Section { - VStack(spacing: 16) { - // Current value display - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Current Value") - .font(.subheadline) - .foregroundStyle(.secondary) - Text(toggleObservable.value.description) - .font(.system(.title2, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(isOverridden ? .orange : .primary) - } - Spacer() - } - .padding(.vertical, 4) - - Divider() - - // Override control - if inputValidationHelper.isBooleanToggle { - booleanOverrideControl - } else { - textOverrideControl - } - } - .padding(.vertical, 8) - - // Reset button as a proper list row - if isOverridden { - Button(role: .destructive) { - showingResetConfirmation = true - } label: { - HStack { - Image(systemName: "arrow.counterclockwise") - .foregroundStyle(.red) - Text("Reset to Default") - } - } - } - } header: { - HStack { - Label("Override", systemImage: "slider.horizontal.3") - if isOverridden { - Spacer() - Text("Active") - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(.orange) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(.orange.opacity(0.15)) - .clipShape(Capsule()) - } - } - } - } - - private var booleanOverrideControl: some View { - HStack { - Text("Override to") - .foregroundStyle(.secondary) - - Spacer() - - HStack(spacing: 12) { - Button { - manager.set(.bool(false), for: toggle.variable) - boolValue = false - showOverrideFeedback() - } label: { - Text("false") - .font(.system(.body, design: .monospaced)) - .fontWeight(.medium) - .foregroundStyle(!boolValue && isOverridden ? .white : .red) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(!boolValue && isOverridden ? Color.red : Color.red.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - - Button { - manager.set(.bool(true), for: toggle.variable) - boolValue = true - showOverrideFeedback() - } label: { - Text("true") - .font(.system(.body, design: .monospaced)) - .fontWeight(.medium) - .foregroundStyle(boolValue && isOverridden ? .white : .green) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(boolValue && isOverridden ? Color.green : Color.green.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - } - - private var textOverrideControl: some View { - VStack(spacing: 12) { - HStack(alignment: inputValidationHelper.isObjectToggle ? .top : .center) { - // Use TextEditor for multiline content (objects, long strings) - if inputValidationHelper.isObjectToggle { - TextEditor(text: $textValue) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 80, maxHeight: 120) - .padding(4) -#if os(iOS) - .background(Color(.systemGray5)) -#else - .background(Color.gray.opacity(0.2)) -#endif - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - TextField("Enter value", text: $textValue) - .font(.system(.body, design: .monospaced)) -#if os(iOS) - .keyboardType(inputValidationHelper.keyboardType) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() -#endif - .onSubmit { - submitOverride() - } - .submitLabel(.done) - .padding(12) -#if os(iOS) - .background(Color(.systemGray5)) -#else - .background(Color.gray.opacity(0.2)) -#endif - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - Button { - submitOverride() - } label: { - Image(systemName: valueOverridden ? "checkmark.circle.fill" : "arrow.up.circle.fill") - .font(.title2) - .foregroundStyle(valueOverridden ? .green : (isValidInput ? .blue : .gray)) - } - .disabled(!isValidInput) - .buttonStyle(.plain) - } - - if inputValidationHelper.toggleNeedsValidation && !textValue.isEmpty { - HStack { - if isValidInput { - Label("Valid \(toggle.value.typeDescription.lowercased())", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else { - Label("Invalid \(toggle.value.typeDescription.lowercased())", systemImage: "xmark.circle.fill") - .font(.caption) - .foregroundStyle(.red) - } - Spacer() - } - } - } - } - - private func submitOverride() { - guard isValidInput else { return } - manager.set(inputValidationHelper.overridingValue(for: textValue), for: toggle.variable) - showOverrideFeedback() -#if os(iOS) - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) -#endif - } - - private func showOverrideFeedback() { - valueOverridden = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - valueOverridden = false - } - } - - // MARK: - Current Value Section - - private var currentValueSection: some View { - Section { - HStack { - Label("Via getter", systemImage: "arrow.right.circle") - Spacer() - Text(manager.value(for: toggle.variable).description) - .font(.system(.footnote, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .contentShape(Rectangle()) - .onTapGesture { - expandedValue = manager.value(for: toggle.variable).description - } - - HStack { - Label("Via publisher", systemImage: "antenna.radiowaves.left.and.right") - Spacer() - Text(toggleObservable.value.description) - .font(.system(.footnote, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .contentShape(Rectangle()) - .onTapGesture { - expandedValue = toggleObservable.value.description - } - } header: { - Label("Live Value", systemImage: "bolt.fill") - } - } - - // MARK: - Providers Section - - private var providersSection: some View { - Section { - ForEach(manager.stackTrace(for: toggle.variable)) { trace in - HStack { - Label(trace.providerName, systemImage: "shippingbox") - .foregroundStyle(trace.value != nil ? .primary : .tertiary) - Spacer() - if let value = trace.value { - Text(value.description) - .font(.system(.footnote, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } else { - Text("—") - .foregroundStyle(.tertiary) - } - } - .contentShape(Rectangle()) - .onTapGesture { - if let value = trace.value { - expandedValue = value.description - } - } - } - } header: { - Label("Provider Stack", systemImage: "square.stack.3d.up") - } footer: { - Text("Values are resolved from top to bottom. First non-nil value wins.") - } - } - - // MARK: - Metadata Section - - private var metadataSection: some View { - Section { - HStack { - Label("Variable", systemImage: "tag") - Spacer() - Text(toggle.variable) - .font(.system(.subheadline, design: .monospaced)) - .foregroundStyle(.secondary) - } - HStack { - Label("Type", systemImage: toggle.value.sfSymbolId) - Spacer() - Text(toggle.value.typeDescription) - .foregroundStyle(.secondary) - } - HStack { - Label("Group", systemImage: "folder") - Spacer() - Text(toggle.metadata.group) - .foregroundStyle(.secondary) - } - } header: { - Label("Metadata", systemImage: "info.circle") - } - } -} - -// MARK: - Expanded Value View - -extension String: @retroactive Identifiable { - public var id: String { self } -} - -private struct ExpandedValueView: View { - let value: String - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationView { - ScrollView { - Text(value) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - .navigationTitle("Value") -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - } - ToolbarItem(placement: .cancellationAction) { - Button { - UIPasteboard.general.string = value - } label: { - Image(systemName: "doc.on.doc") - } - } - } -#else - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - } - } -#endif - } - } -} - -struct ToggleDetailView_Previews: PreviewProvider { - static var previews: some View { - let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! - let mutableValueProvider = PersistentValueProvider(userDefaults: .standard) - let valueProviders = [try! LocalValueProvider(jsonUrl: datasourceUrl)] - let manager = try! ToggleManager(mutableValueProvider: mutableValueProvider, - valueProviders: valueProviders, - datasourceUrl: datasourceUrl) - let content = try! Data(contentsOf: datasourceUrl) - let datasource = try! JSONDecoder().decode(Datasource.self, from: content) - NavigationView { - ToggleDetailView(manager: manager, toggle: datasource.toggles[0]) - } - } -} diff --git a/Sources/Views/ToggleDetailView/BooleanOverrideControl.swift b/Sources/Views/ToggleDetailView/BooleanOverrideControl.swift new file mode 100644 index 0000000..a2018f8 --- /dev/null +++ b/Sources/Views/ToggleDetailView/BooleanOverrideControl.swift @@ -0,0 +1,94 @@ +// BooleanOverrideControl.swift + +import SwiftUI + +struct BooleanOverrideControl: View { + let manager: ToggleManager + let toggle: Toggle + + @Binding var boolValue: Bool + @Binding var valueOverridden: Bool + + private var isOverridden: Bool { + manager.isOverridden(toggle.variable) + } + + var body: some View { + HStack { + Text("Override to") + .foregroundStyle(.secondary) + + Spacer() + + HStack(spacing: 12) { + Button { + manager.set(.bool(false), for: toggle.variable) + boolValue = false + showOverrideFeedback() + } label: { + Text("false") + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(!boolValue && isOverridden ? .white : .red) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(!boolValue && isOverridden ? Color.red : Color.red.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Button { + manager.set(.bool(true), for: toggle.variable) + boolValue = true + showOverrideFeedback() + } label: { + Text("true") + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(boolValue && isOverridden ? .white : .green) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(boolValue && isOverridden ? Color.green : Color.green.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } + + private func showOverrideFeedback() { + valueOverridden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + valueOverridden = false + } + } +} + +private struct BooleanOverrideControlPreview: View { + @State private var boolValue = true + @State private var valueOverridden = false + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let boolToggle = datasource.toggles.first { toggle in + if case .bool = toggle.value { return true } + return false + }! + List { + BooleanOverrideControl( + manager: manager, + toggle: boolToggle, + boolValue: $boolValue, + valueOverridden: $valueOverridden + ) + } + } +} + +#Preview("BooleanOverrideControl") { + BooleanOverrideControlPreview() +} diff --git a/Sources/Views/ToggleDetailView/ExpandedValueView.swift b/Sources/Views/ToggleDetailView/ExpandedValueView.swift new file mode 100644 index 0000000..ad03aa5 --- /dev/null +++ b/Sources/Views/ToggleDetailView/ExpandedValueView.swift @@ -0,0 +1,98 @@ +// ExpandedValueView.swift + +import SwiftUI +#if os(iOS) +import UIKit +#endif + +extension String: @retroactive Identifiable { + public var id: String { self } +} + +struct ExpandedValueView: View { + let value: String + @Environment(\.dismiss) private var dismiss + @State private var showCopiedFeedback = false + + var body: some View { + NavigationView { + ScrollView { + Text(value) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle("Value") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + UIPasteboard.general.string = value + withAnimation(.easeInOut(duration: 0.2)) { + showCopiedFeedback = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.2)) { + showCopiedFeedback = false + } + } + } label: { + if #available(iOS 17.0, *) { + Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") + .contentTransition(.symbolEffect(.replace)) + } else { + Image(systemName: "doc.on.doc") + } + } + } + } + .overlay(alignment: .bottom) { + if showCopiedFeedback { + Text("Copied to clipboard") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.black.opacity(0.75), in: Capsule()) + .padding(.bottom, 20) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } +#else + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } +#endif + } + } +} + +#Preview("ExpandedValueView - Short") { + ExpandedValueView(value: "Hello World") +} + +#Preview("ExpandedValueView - JSON") { + ExpandedValueView(value: """ + { + "name": "John Doe", + "age": 30, + "email": "john@example.com", + "address": { + "street": "123 Main St", + "city": "San Francisco" + } + } + """) +} diff --git a/Sources/Views/ToggleDetailView/LiveValueSection.swift b/Sources/Views/ToggleDetailView/LiveValueSection.swift new file mode 100644 index 0000000..b651cbc --- /dev/null +++ b/Sources/Views/ToggleDetailView/LiveValueSection.swift @@ -0,0 +1,67 @@ +// LiveValueSection.swift + +import SwiftUI + +struct LiveValueSection: View { + let manager: ToggleManager + let toggle: Toggle + + @ObservedObject var toggleObservable: ToggleObservable + @Binding var expandedValue: String? + + var body: some View { + Section { + HStack { + Label("Via getter", systemImage: "arrow.right.circle") + Spacer() + Text(manager.value(for: toggle.variable).description) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .contentShape(Rectangle()) + .onTapGesture { + expandedValue = manager.value(for: toggle.variable).description + } + + HStack { + Label("Via publisher", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + Text(toggleObservable.value.description) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .contentShape(Rectangle()) + .onTapGesture { + expandedValue = toggleObservable.value.description + } + } header: { + Label("Live Value", systemImage: "bolt.fill") + } + } +} + +private struct LiveValueSectionPreview: View { + @State private var expandedValue: String? = nil + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + List { + LiveValueSection( + manager: manager, + toggle: datasource.toggles[0], + toggleObservable: ToggleObservable(manager: manager, variable: datasource.toggles[0].variable), + expandedValue: $expandedValue + ) + } + } +} + +#Preview("LiveValueSection") { + LiveValueSectionPreview() +} diff --git a/Sources/Views/ToggleDetailView/MetadataSection.swift b/Sources/Views/ToggleDetailView/MetadataSection.swift new file mode 100644 index 0000000..ab46c0f --- /dev/null +++ b/Sources/Views/ToggleDetailView/MetadataSection.swift @@ -0,0 +1,42 @@ +// MetadataSection.swift + +import SwiftUI + +struct MetadataSection: View { + let toggle: Toggle + + var body: some View { + Section { + HStack { + Label("Variable", systemImage: "tag") + Spacer() + Text(toggle.variable) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.secondary) + } + HStack { + Label("Type", systemImage: toggle.value.sfSymbolId) + Spacer() + Text(toggle.value.typeDescription) + .foregroundStyle(.secondary) + } + HStack { + Label("Group", systemImage: "folder") + Spacer() + Text(toggle.metadata.group) + .foregroundStyle(.secondary) + } + } header: { + Label("Metadata", systemImage: "info.circle") + } + } +} + +#Preview("MetadataSection") { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + List { + MetadataSection(toggle: datasource.toggles[0]) + } +} diff --git a/Sources/Views/ToggleDetailView/OverrideSection.swift b/Sources/Views/ToggleDetailView/OverrideSection.swift new file mode 100644 index 0000000..d2b761e --- /dev/null +++ b/Sources/Views/ToggleDetailView/OverrideSection.swift @@ -0,0 +1,169 @@ +// OverrideSection.swift + +import SwiftUI +#if os(iOS) +import UIKit +#endif + +struct OverrideSection: View { + let manager: ToggleManager + let toggle: Toggle + let inputValidationHelper: InputValidationHelper + + @ObservedObject var toggleObservable: ToggleObservable + + @Binding var boolValue: Bool + @Binding var textValue: String + @Binding var isValidInput: Bool + @Binding var valueOverridden: Bool + @Binding var showingResetConfirmation: Bool + + private var isOverridden: Bool { + manager.isOverridden(toggle.variable) + } + + var body: some View { + Section { + VStack(spacing: 16) { + // Current value display + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Current Value") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(toggleObservable.value.description) + .font(.system(.title2, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(isOverridden ? .orange : .primary) + } + Spacer() + } + .padding(.vertical, 4) + + Divider() + + // Override control + if inputValidationHelper.isBooleanToggle { + BooleanOverrideControl( + manager: manager, + toggle: toggle, + boolValue: $boolValue, + valueOverridden: $valueOverridden + ) + } else { + TextOverrideControl( + manager: manager, + toggle: toggle, + inputValidationHelper: inputValidationHelper, + textValue: $textValue, + isValidInput: $isValidInput, + valueOverridden: $valueOverridden + ) + } + } + .padding(.vertical, 8) + + // Reset button as a proper list row + if isOverridden { + Button(role: .destructive) { + showingResetConfirmation = true + } label: { + HStack { + Image(systemName: "arrow.counterclockwise") + .foregroundStyle(.red) + Text("Reset to Default") + } + } + } + } header: { + HStack { + Label("Override", systemImage: "slider.horizontal.3") + if isOverridden { + Spacer() + Text("Active") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.orange) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.orange.opacity(0.15)) + .clipShape(Capsule()) + } + } + } + } +} + +private struct OverrideSectionBooleanPreview: View { + @State private var boolValue = true + @State private var textValue = "true" + @State private var isValidInput = true + @State private var valueOverridden = false + @State private var showingResetConfirmation = false + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let boolToggle = datasource.toggles.first { toggle in + if case .bool = toggle.value { return true } + return false + }! + List { + OverrideSection( + manager: manager, + toggle: boolToggle, + inputValidationHelper: InputValidationHelper(toggle: boolToggle), + toggleObservable: ToggleObservable(manager: manager, variable: boolToggle.variable), + boolValue: $boolValue, + textValue: $textValue, + isValidInput: $isValidInput, + valueOverridden: $valueOverridden, + showingResetConfirmation: $showingResetConfirmation + ) + } + } +} + +private struct OverrideSectionStringPreview: View { + @State private var boolValue = false + @State private var textValue = "Hello World" + @State private var isValidInput = true + @State private var valueOverridden = false + @State private var showingResetConfirmation = false + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let stringToggle = datasource.toggles.first { toggle in + if case .string = toggle.value { return true } + return false + }! + List { + OverrideSection( + manager: manager, + toggle: stringToggle, + inputValidationHelper: InputValidationHelper(toggle: stringToggle), + toggleObservable: ToggleObservable(manager: manager, variable: stringToggle.variable), + boolValue: $boolValue, + textValue: $textValue, + isValidInput: $isValidInput, + valueOverridden: $valueOverridden, + showingResetConfirmation: $showingResetConfirmation + ) + } + } +} + +#Preview("OverrideSection - Boolean") { + OverrideSectionBooleanPreview() +} + +#Preview("OverrideSection - String") { + OverrideSectionStringPreview() +} diff --git a/Sources/Views/ToggleDetailView/ProviderStackSection.swift b/Sources/Views/ToggleDetailView/ProviderStackSection.swift new file mode 100644 index 0000000..4549bea --- /dev/null +++ b/Sources/Views/ToggleDetailView/ProviderStackSection.swift @@ -0,0 +1,65 @@ +// ProviderStackSection.swift + +import SwiftUI + +struct ProviderStackSection: View { + let manager: ToggleManager + let toggle: Toggle + + @Binding var expandedValue: String? + + var body: some View { + Section { + ForEach(manager.stackTrace(for: toggle.variable)) { trace in + HStack { + Label(trace.providerName, systemImage: "shippingbox") + .foregroundStyle(trace.value != nil ? .primary : .tertiary) + Spacer() + if let value = trace.value { + Text(value.description) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text("—") + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if let value = trace.value { + expandedValue = value.description + } + } + } + } header: { + Label("Provider Stack", systemImage: "square.stack.3d.up") + } footer: { + Text("Values are resolved from top to bottom. First non-nil value wins.") + } + } +} + +private struct ProviderStackSectionPreview: View { + @State private var expandedValue: String? = nil + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + valueProviders: [try! LocalValueProvider(jsonUrl: datasourceUrl)], + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + List { + ProviderStackSection( + manager: manager, + toggle: datasource.toggles[0], + expandedValue: $expandedValue + ) + } + } +} + +#Preview("ProviderStackSection") { + ProviderStackSectionPreview() +} diff --git a/Sources/Views/ToggleDetailView/TextOverrideControl.swift b/Sources/Views/ToggleDetailView/TextOverrideControl.swift new file mode 100644 index 0000000..1f1eca2 --- /dev/null +++ b/Sources/Views/ToggleDetailView/TextOverrideControl.swift @@ -0,0 +1,160 @@ +// TextOverrideControl.swift + +import SwiftUI +#if os(iOS) +import UIKit +#endif + +struct TextOverrideControl: View { + let manager: ToggleManager + let toggle: Toggle + let inputValidationHelper: InputValidationHelper + + @Binding var textValue: String + @Binding var isValidInput: Bool + @Binding var valueOverridden: Bool + + var body: some View { + VStack(spacing: 12) { + HStack(alignment: inputValidationHelper.isObjectToggle ? .top : .center) { + // Use TextEditor for multiline content (objects, long strings) + if inputValidationHelper.isObjectToggle { + TextEditor(text: $textValue) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 80, maxHeight: 120) + .padding(4) +#if os(iOS) + .background(Color(.systemGray5)) +#else + .background(Color.gray.opacity(0.2)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + TextField("Enter value", text: $textValue) + .font(.system(.body, design: .monospaced)) +#if os(iOS) + .keyboardType(inputValidationHelper.keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() +#endif + .onSubmit { + submitOverride() + } + .submitLabel(.done) + .padding(12) +#if os(iOS) + .background(Color(.systemGray5)) +#else + .background(Color.gray.opacity(0.2)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + Button { + submitOverride() + } label: { + Image(systemName: valueOverridden ? "checkmark.circle.fill" : "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(valueOverridden ? .green : (isValidInput ? .blue : .gray)) + } + .disabled(!isValidInput) + .buttonStyle(.plain) + } + + if inputValidationHelper.toggleNeedsValidation && !textValue.isEmpty { + HStack { + if isValidInput { + Label("Valid \(toggle.value.typeDescription.lowercased())", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Label("Invalid \(toggle.value.typeDescription.lowercased())", systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + } + Spacer() + } + } + } + } + + private func submitOverride() { + guard isValidInput else { return } + manager.set(inputValidationHelper.overridingValue(for: textValue), for: toggle.variable) + showOverrideFeedback() +#if os(iOS) + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) +#endif + } + + private func showOverrideFeedback() { + valueOverridden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + valueOverridden = false + } + } +} + +private struct TextOverrideControlStringPreview: View { + @State private var textValue = "Hello World" + @State private var isValidInput = true + @State private var valueOverridden = false + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let stringToggle = datasource.toggles.first { toggle in + if case .string = toggle.value { return true } + return false + }! + List { + TextOverrideControl( + manager: manager, + toggle: stringToggle, + inputValidationHelper: InputValidationHelper(toggle: stringToggle), + textValue: $textValue, + isValidInput: $isValidInput, + valueOverridden: $valueOverridden + ) + } + } +} + +private struct TextOverrideControlIntegerPreview: View { + @State private var textValue = "42" + @State private var isValidInput = true + @State private var valueOverridden = false + + var body: some View { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let manager = try! ToggleManager(mutableValueProvider: InMemoryValueProvider(), + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + let intToggle = datasource.toggles.first { toggle in + if case .int = toggle.value { return true } + return false + }! + List { + TextOverrideControl( + manager: manager, + toggle: intToggle, + inputValidationHelper: InputValidationHelper(toggle: intToggle), + textValue: $textValue, + isValidInput: $isValidInput, + valueOverridden: $valueOverridden + ) + } + } +} + +#Preview("TextOverrideControl - String") { + TextOverrideControlStringPreview() +} + +#Preview("TextOverrideControl - Integer") { + TextOverrideControlIntegerPreview() +} diff --git a/Sources/Views/ToggleDetailView/ToggleDetailView.swift b/Sources/Views/ToggleDetailView/ToggleDetailView.swift new file mode 100644 index 0000000..4ff5222 --- /dev/null +++ b/Sources/Views/ToggleDetailView/ToggleDetailView.swift @@ -0,0 +1,109 @@ +// ToggleDetailView.swift + +import SwiftUI + +struct ToggleDetailView: View { + + let manager: ToggleManager + let toggle: Toggle + let inputValidationHelper: InputValidationHelper + + @State private var boolValue: Bool = false + @State private var textValue: String = "" + @State private var isValidInput: Bool = false + @State private var valueOverridden: Bool = false + @State private var showingResetConfirmation: Bool = false + @State private var expandedValue: String? = nil + + @ObservedObject var toggleObservable: ToggleObservable + + init(manager: ToggleManager, toggle: Toggle) { + self.manager = manager + self.toggle = toggle + self.toggleObservable = ToggleObservable(manager: manager, variable: toggle.variable) + self.inputValidationHelper = InputValidationHelper(toggle: toggle) + } + + var body: some View { + List { + if manager.mutableValueProvider != nil { + OverrideSection( + manager: manager, + toggle: toggle, + inputValidationHelper: inputValidationHelper, + toggleObservable: toggleObservable, + boolValue: $boolValue, + textValue: $textValue, + isValidInput: $isValidInput, + valueOverridden: $valueOverridden, + showingResetConfirmation: $showingResetConfirmation + ) + } + + LiveValueSection( + manager: manager, + toggle: toggle, + toggleObservable: toggleObservable, + expandedValue: $expandedValue + ) + + ProviderStackSection( + manager: manager, + toggle: toggle, + expandedValue: $expandedValue + ) + + MetadataSection(toggle: toggle) + } +#if os(iOS) + .listStyle(.insetGrouped) + .navigationBarTitleDisplayMode(.inline) +#endif + .navigationTitle(toggle.metadata.description) + .sheet(item: $expandedValue) { value in + ExpandedValueView(value: value) + } + .onAppear { + syncValues() + } + .onChange(of: textValue) { newValue in + isValidInput = inputValidationHelper.isInputValid(newValue) + } + .onChange(of: toggleObservable.value) { _ in + syncValues() + } + .confirmationDialog( + "Reset to Default", + isPresented: $showingResetConfirmation, + titleVisibility: .visible + ) { + Button("Reset", role: .destructive) { + manager.delete(toggle.variable) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will remove the override and restore the default value.") + } + } + + private func syncValues() { + if case .bool(let value) = manager.value(for: toggle.variable) { + boolValue = value + } + textValue = manager.value(for: toggle.variable).description + } +} + +#Preview { + let datasourceUrl = Bundle.module.url(forResource: "PreviewDatasource", withExtension: "json")! + let mutableValueProvider = PersistentValueProvider(userDefaults: .standard) + let valueProviders = [try! LocalValueProvider(jsonUrl: datasourceUrl)] + let manager = try! ToggleManager(mutableValueProvider: mutableValueProvider, + valueProviders: valueProviders, + datasourceUrl: datasourceUrl) + let content = try! Data(contentsOf: datasourceUrl) + let datasource = try! JSONDecoder().decode(Datasource.self, from: content) + NavigationView { + ToggleDetailView(manager: manager, toggle: datasource.toggles[0]) + } +} From 716bcd01533e1cf2c8fef1c7a60c9df10a17df2e Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 15:24:39 +0000 Subject: [PATCH 11/11] Unit tests fix --- Tests/Suites/Extensions/Value+UtilitiesTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Suites/Extensions/Value+UtilitiesTests.swift b/Tests/Suites/Extensions/Value+UtilitiesTests.swift index a6d6baa..5179fb4 100644 --- a/Tests/Suites/Extensions/Value+UtilitiesTests.swift +++ b/Tests/Suites/Extensions/Value+UtilitiesTests.swift @@ -54,11 +54,11 @@ final class Value_UtilitiesTests: XCTestCase { } func test_booleanValueSFSybol() throws { - XCTAssertEqual(Value.bool(true).sfSymbolId, "power") + XCTAssertEqual(Value.bool(true).sfSymbolId, "switch.2") } func test_intValueSFSybol() throws { - XCTAssertEqual(Value.int(42).sfSymbolId, "number") + XCTAssertEqual(Value.int(42).sfSymbolId, "number.square") } func test_numberValueSFSybol() throws { @@ -66,7 +66,7 @@ final class Value_UtilitiesTests: XCTestCase { } func test_stringValueSFSybol() throws { - XCTAssertEqual(Value.string("Hello World").sfSymbolId, "textformat.abc") + XCTAssertEqual(Value.string("Hello World").sfSymbolId, "textformat") } func test_secureValueSFSybol() throws {