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/Sources/ToggleManager/ToggleManager+Overrides.swift b/Sources/ToggleManager/ToggleManager+Overrides.swift index 38e7df5..e7993f8 100644 --- a/Sources/ToggleManager/ToggleManager+Overrides.swift +++ b/Sources/ToggleManager/ToggleManager+Overrides.swift @@ -12,20 +12,40 @@ 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 { 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 } } 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 } diff --git a/Sources/Views/ToggleDetailView.swift b/Sources/Views/ToggleDetailView.swift deleted file mode 100644 index fdcdd5d..0000000 --- a/Sources/Views/ToggleDetailView.swift +++ /dev/null @@ -1,201 +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 refresh: Bool = false - @State private var valueOverridden: Bool = false - - @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 { - listView - } - - private var listView: some View { - List { - toggleInformationSection - currentValueSection - // cacheSection - providersSection - if manager.mutableValueProvider != nil { - overrideValueSection - } - } - .navigationTitle(toggle.metadata.description) - .onAppear { - if case .bool(let value) = manager.value(for: toggle.variable) { - boolValue = value - } - textValue = manager.value(for: toggle.variable).description - } - .onChange(of: textValue) { newValue in - isValidInput = inputValidationHelper.isInputValid(newValue) - } - .onChange(of: refresh) { _ in } - } - - private var toggleInformationSection: some View { - Section(header: Text("Information")) { - HStack { - Text("Variable") - Spacer() - Text(toggle.variable) - } - HStack { - Text("Value type") - Spacer() - Text(toggle.value.typeDescription) - } - HStack { - Text("Group") - Spacer() - Text(toggle.metadata.group) - } - } - } - - private var currentValueSection: some View { - Section(header: Text("Current returned value")) { - HStack { - Text("Via the getter") - Spacer() - Text(manager.value(for: toggle.variable).description) - } - HStack { - Text("Via the publisher") - Spacer() - Text(toggleObservable.value.description) - } - } - } - - 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() - } - } - } - } - - private var providersSection: some View { - Section(header: Text("Providers"), - footer: Text("The providers are listed in priority order.")) { - ForEach(manager.stackTrace(for: toggle.variable)) { trace in - HStack { - Text(trace.providerName) - Spacer() - if let value = trace.value { - Text(value.description) - .font(.body) - } else { - Text("nil") - .font(.body) - .italic() - } - } - } - } - } - - private var overrideValueSection: 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 - } - Spacer() - overrideButtonView - } - } 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) - } - } - } - } - - 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 - } - } - .disabled(!isValidInput) - } -} - -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) - 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]) + } +} 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.swift b/Sources/Views/TogglesView/TogglesView.swift similarity index 63% rename from Sources/Views/TogglesView.swift rename to Sources/Views/TogglesView/TogglesView.swift index 7495f0a..b3d6d15 100644 --- a/Sources/Views/TogglesView.swift +++ b/Sources/Views/TogglesView/TogglesView.swift @@ -5,36 +5,6 @@ public import SwiftUI /// A view showcasing all toggles from a provided datasource. public struct TogglesView: View { - private struct ToggleRow: View { - - private var toggle: Toggle - - @ObservedObject var toggleObservable: ToggleObservable - - init(manager: ToggleManager, toggle: Toggle) { - self.toggle = toggle - self.toggleObservable = ToggleObservable(manager: manager, variable: toggle.variable) - } - - var body: some View { - HStack(alignment: .center) { - Image(systemName: toggle.value.sfSymbolId) - .padding(.trailing, 5.0) - VStack(alignment: .leading) { - Text(toggle.metadata.description) - .bold() - .multilineTextAlignment(.leading) - Text(toggle.variable) - .multilineTextAlignment(.leading) - } - .padding([.all], 5.0) - Spacer() - Text(toggleObservable.value.description) - .multilineTextAlignment(.trailing) - } - } - } - @ObservedObject public var manager: ToggleManager public let datasourceUrl: URL @@ -43,9 +13,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 +29,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 +64,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) } @@ -123,12 +97,12 @@ public struct TogglesView: View { } } -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) - return TogglesView(manager: manager, datasourceUrl: datasourceUrl) +#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) } } diff --git a/Tests/Suites/Extensions/Value+UtilitiesTests.swift b/Tests/Suites/Extensions/Value+UtilitiesTests.swift index e3d32a3..5179fb4 100644 --- a/Tests/Suites/Extensions/Value+UtilitiesTests.swift +++ b/Tests/Suites/Extensions/Value+UtilitiesTests.swift @@ -62,7 +62,7 @@ final class Value_UtilitiesTests: XCTestCase { } func test_numberValueSFSybol() throws { - XCTAssertEqual(Value.number(3.1416).sfSymbolId, "number.square.fill") + XCTAssertEqual(Value.number(3.1416).sfSymbolId, "function") } func test_stringValueSFSybol() throws { @@ -70,10 +70,10 @@ final class Value_UtilitiesTests: XCTestCase { } 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") } } 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")) + } } 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) + } } 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") } }