diff --git a/.swiftlint.yml b/.swiftlint.yml index 9f4087bd..d523911d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -20,7 +20,6 @@ analyzer_rules: opt_in_rules: - array_init - - attributes - closure_end_indentation - closure_spacing - collection_alignment diff --git a/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj b/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj index cb0c65d5..4663a4e3 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj +++ b/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 733219BF2BE3C11100747AC2 /* ConfidenceOpenFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 733219BE2BE3C11100747AC2 /* ConfidenceOpenFeature */; }; + 735EADF52CF9B64E007BC42C /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 735EADF42CF9B64E007BC42C /* LoginView.swift */; }; C770C99A2A739FBC00C2AC8C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C770C9962A739FBC00C2AC8C /* Preview Assets.xcassets */; }; C770C99B2A739FBC00C2AC8C /* ConfidenceDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770C9972A739FBC00C2AC8C /* ConfidenceDemoApp.swift */; }; C770C99C2A739FBC00C2AC8C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770C9982A739FBC00C2AC8C /* ContentView.swift */; }; @@ -35,6 +36,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 735EADF42CF9B64E007BC42C /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; C770C9682A739FA000C2AC8C /* ConfidenceDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ConfidenceDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; C770C9782A739FA100C2AC8C /* ConfidenceDemoAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfidenceDemoAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C770C9822A739FA100C2AC8C /* ConfidenceDemoAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfidenceDemoAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -103,6 +105,7 @@ C770C9AA2A73A06000C2AC8C /* Info.plist */, C770C9992A739FBC00C2AC8C /* Assets.xcassets */, C770C9972A739FBC00C2AC8C /* ConfidenceDemoApp.swift */, + 735EADF42CF9B64E007BC42C /* LoginView.swift */, C770C9982A739FBC00C2AC8C /* ContentView.swift */, C770C9952A739FBC00C2AC8C /* Preview Content */, ); @@ -285,6 +288,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 735EADF52CF9B64E007BC42C /* LoginView.swift in Sources */, C770C99C2A739FBC00C2AC8C /* ContentView.swift in Sources */, C770C99B2A739FBC00C2AC8C /* ConfidenceDemoApp.swift in Sources */, ); diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index 6d79beb7..f0ca422e 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -1,50 +1,90 @@ import Confidence import SwiftUI -class Status: ObservableObject { - enum State { - case unknown - case ready - case error(Error?) - } +@main +struct ConfidenceDemoApp: App { + @AppStorage("loggedUser") + private var loggedUser: String? + @AppStorage("appVersion") + private var appVersion = 0 - @Published var state: State = .unknown -} + private let confidence: Confidence + private let flaggingState = ExperimentationFlags() + private let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" + init() { + @AppStorage("appVersion") var appVersion = 0 + @AppStorage("loggedUser") var loggedUser: String? + appVersion += 1 // Simulate update of the app on every new run + var context = ["app_version": ConfidenceValue.init(integer: appVersion)] + if let user = loggedUser { + context["user_id"] = ConfidenceValue.init(string: user) + } -@main -struct ConfidenceDemoApp: App { - @StateObject private var lifecycleObserver = ConfidenceAppLifecycleProducer() + confidence = Confidence + .Builder(clientSecret: secret, loggerLevel: .TRACE) + .withContext(initialContext: context) + .build() + do { + // NOTE: here we are activating all the flag values from storage, regardless of how `context` looks now + try confidence.activate() + } catch { + flaggingState.state = .error(ExperimentationFlags.CustomError(message: error.localizedDescription)) + } + // flaggingState.color is set here at startup and remains immutable until a user logs out + flaggingState.color = ContentView.getColor( + color: confidence.getValue( + key: "swift-demoapp.color", + defaultValue: "Gray")) + + self.appVersion = appVersion + self.loggedUser = loggedUser + updateConfidence() + } var body: some Scene { WindowGroup { - let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" - let confidence = Confidence.Builder(clientSecret: secret, loggerLevel: .TRACE) - .withContext(initialContext: [ - "targeting_key": ConfidenceValue(string: UUID.init().uuidString), - "user_id": .init(string: "user2") - ]) - .build() - - let status = Status() - - ContentView(confidence: confidence, status: status) - .task { - do { - confidence.track(producer: lifecycleObserver) - try await self.setup(confidence: confidence) - status.state = .ready - } catch { - status.state = .error(error) - print(error.localizedDescription) - } - } + Text("Client secret: \(secret)") + .font(.caption) + if loggedUser == nil { + LoginView(confidence: confidence) + .environmentObject(flaggingState) + } else { + ContentView(confidence: confidence) + .environmentObject(flaggingState) + } + } + } + + private func updateConfidence() { + Task { + do { + flaggingState.state = .loading + try await Task.sleep(nanoseconds: 2 * 1_000_000_000) // simulating slow network + // The flags in storage are refreshed for the current `context`, and activated + // After this line, fresh (and potentially new) flags values can be accessed + try await confidence.fetchAndActivate() + flaggingState.state = .ready + } catch { + flaggingState.state = .error(ExperimentationFlags.CustomError(message: error.localizedDescription)) + } } } } -extension ConfidenceDemoApp { - func setup(confidence: Confidence) async throws { - try await confidence.fetchAndActivate() +class ExperimentationFlags: ObservableObject { + var color: Color = .red // This is set on applicaaton start, and reset on user logout + @Published var state: State = .notReady + + enum State: Equatable { + case unknown + case notReady + case loading + case ready + case error(CustomError?) + } + + public struct CustomError: Error, Equatable { + let message: String } } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift index cfa931f8..7e3f5793 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift @@ -3,59 +3,110 @@ import Confidence import Combine struct ContentView: View { - @ObservedObject var status: Status - @StateObject var text = DisplayText() - @StateObject var color = FlagColor() + @EnvironmentObject + var flaggingState: ExperimentationFlags + @AppStorage("loggedUser") + private var loggedUser: String? + @State + private var isLoggingOut = false + @State + private var loggedOut = false + @State + private var textColor = Color.red private let confidence: Confidence - init(confidence: Confidence, status: Status) { + init(confidence: Confidence, color: Color? = nil) { self.confidence = confidence - self.status = status } var body: some View { - if case .ready = status.state { - VStack { - Image(systemName: "flag") - .imageScale(.large) - .foregroundColor(color.color) - .padding(10) - Text(text.text) - Button("Get remote flag value") { - text.text = confidence.getValue(key: "swift-demoapp.color", defaultValue: "ERROR") - if text.text == "Green" { - color.color = .green - } else if text.text == "Yellow" { - color.color = .yellow - } else { - color.color = .red + NavigationStack { + if flaggingState.state == .loading && !isLoggingOut { + VStack { + Spacer() + Text("Login successful: \(loggedUser ?? "?")") + Text("We are preparing your experience...") + ProgressView() + } + } else { + if flaggingState.state == .ready { + VStack { + Spacer() + if let user = loggedUser { + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + VStack { + Text("Hello \(user)") + .font(.largeTitle) + .foregroundStyle(ContentView.getColor(color: eval.value)) + .padding() + Text("This text only appears after a successful flag fetching") + .font(.caption) + .foregroundStyle(ContentView.getColor(color: eval.value)) + .padding() + } + } } } - Button("Flush 🚽") { - confidence.flush() + NavigationLink(destination: AboutPage()) { + Text("Navigate") } + Button("Logout") { + isLoggingOut = true + loggedUser = nil + flaggingState.state = .loading + flaggingState.color = .gray + Task { + await confidence.removeContext(key: "user_id") + flaggingState.state = .ready + } + loggedOut = true + } + .navigationDestination(isPresented: $loggedOut) { + LoginView(confidence: confidence) + } + Spacer() } - .padding() - } else if case .error(let error) = status.state { - VStack { - Text("Provider Error") - Text(error?.localizedDescription ?? "An unknow error has occured.") - .foregroundColor(.red) - } - } else { - VStack { - ProgressView() + ZStack { + VStack { + Spacer() + Text("This text color is set on onAppear, doesn't wait for flag fetch") + .font(.caption) + .foregroundStyle(textColor) + Text("This text color dynamically changes on each flags fetch") + .font(.caption) + .foregroundStyle(ContentView.getColor( + color: confidence.getValue( + key: "swift-demoapp.color", + defaultValue: "Gray"))) + Text("This text color is fixed from app start, doesn't react on flag fetches") + .font(.caption) + .foregroundStyle(flaggingState.color) + } + }.onAppear { + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + print(">> Evaluation reason: \(eval)") + textColor = ContentView.getColor(color: eval.value) } } } -} -class DisplayText: ObservableObject { - @Published var text = "Hello World!" + static func getColor(color: String) -> Color { + switch color { + case "Green": + return .green + case "Yellow": + return .yellow + case "Gray": + return .gray + default: + return .red + } + } } - -class FlagColor: ObservableObject { - @Published var color: Color = .black +struct AboutPage: View { + var body: some View { + Text("Mock Page") + } } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift new file mode 100644 index 00000000..81ea9362 --- /dev/null +++ b/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import Confidence + +struct LoginView: View { + @EnvironmentObject + var flaggingState: ExperimentationFlags + @AppStorage("loggedUser") + private var loggedUser: String? + @State + private var loginCompleted = false + @State + private var flagsLoaded = false + @State + private var loggingIn = false + + private let confidence: Confidence + + init(confidence: Confidence) { + self.confidence = confidence + } + + var body: some View { + NavigationStack { + VStack { + Spacer() + Button("Login as user1") { + do { + try confidence.activate() + } catch { + flaggingState.state = .error( + ExperimentationFlags.CustomError(message: error.localizedDescription)) + } + + flaggingState.color = ContentView.getColor( + color: confidence.getValue(key: "swift-demoapp.color", defaultValue: "Gray") + ) + + // Simulating a module that handles feature flagging state during login + Task { + flaggingState.state = .loading + try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) // simulating network delay + // putContext adds the user_id field to the evaluation context and fetches values for it + await confidence.putContext(context: ["user_id": .init(string: "user1")]) + flaggingState.state = .ready + } + + // Simulating a module that handles the actual login mechanism for a user + Task { + loggingIn = true + try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) // simulating network delay + loggedUser = "user1" + loggingIn = false + loginCompleted = true + } + } + .navigationDestination(isPresented: $loginCompleted) { + ContentView(confidence: confidence) + } + + if loggingIn { + ProgressView() + } + + Spacer() + } + } + } +} diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index a794f3d8..d60b762c 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -64,7 +64,6 @@ public class Confidence: ConfidenceEventSender { } let savedFlags = try storage.load(defaultValue: FlagResolution.EMPTY) cache = savedFlags - debugLogger?.logFlags(action: "Activate", flag: "") } } @@ -111,7 +110,6 @@ public class Confidence: ConfidenceEventSender { flags: resolvedFlags.resolvedValues, resolveToken: resolvedFlags.resolveToken ?? "" ) - debugLogger?.logFlags(action: "Fetch", flag: "") try storage.save(data: resolution) } @@ -252,7 +250,7 @@ public class Confidence: ConfidenceEventSender { } contextSubject.value = map debugLogger?.logContext( - action: "PutContext", + action: "PutContextLocal", context: contextSubject.value) } } @@ -270,7 +268,7 @@ public class Confidence: ConfidenceEventSender { do { try await fetchAndActivate() debugLogger?.logContext( - action: "PutContext & FetchAndActivate", + action: "PutContext - Done with FetchAndActivate", context: contextSubject.value) } catch { debugLogger?.logMessage( @@ -296,7 +294,7 @@ public class Confidence: ConfidenceEventSender { do { try await self.fetchAndActivate() debugLogger?.logContext( - action: "PutContext & FetchAndActivate", + action: "PutContext - Done with FetchAndActivate", context: contextSubject.value) } catch { debugLogger?.logMessage( @@ -318,7 +316,7 @@ public class Confidence: ConfidenceEventSender { do { try await self.fetchAndActivate() debugLogger?.logContext( - action: "RemoveContextKey & FetchAndActivate", + action: "RemoveContext - Done with FetchAndActivate", context: contextSubject.value) } catch { debugLogger?.logMessage( diff --git a/Sources/Confidence/DebugLogger.swift b/Sources/Confidence/DebugLogger.swift index 5535c1b3..1f47dcd0 100644 --- a/Sources/Confidence/DebugLogger.swift +++ b/Sources/Confidence/DebugLogger.swift @@ -5,6 +5,7 @@ internal protocol DebugLogger { func logEvent(action: String, event: ConfidenceEvent?) func logMessage(message: String, isWarning: Bool) func logFlags(action: String, flag: String) + func logFlags(action: String, context: ConfidenceStruct) func logContext(action: String, context: ConfidenceStruct) func logResolveDebugURL(flagName: String, context: ConfidenceStruct) } @@ -62,6 +63,10 @@ internal class DebugLoggerImpl: DebugLogger { log(messageLevel: .TRACE, message: "[\(action)] \(flag)") } + func logFlags(action: String, context: ConfidenceStruct) { + log(messageLevel: .TRACE, message: "[\(action)] \(context)") + } + func logContext(action: String, context: ConfidenceStruct) { log(messageLevel: .TRACE, message: "[\(action)] \(context)") } diff --git a/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift b/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift index 20d24a95..e2a53282 100644 --- a/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift +++ b/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift @@ -29,6 +29,14 @@ internal class DebugLoggerFake: DebugLogger { // no-op } + func logFlags(action: String, flag: String, resolveToken: String) { + // no-op + } + + func logFlags(action: String, context: ConfidenceStruct) { + // no-op + } + func getUploadBatchSuccessCount() -> Int { return uploadBatchSuccessCounter.get() }