diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index fe2173c..5beb618 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI struct ContentView: View { @@ -15,6 +16,7 @@ struct ContentView: View { @State private var sessionExpiredObserver: NSObjectProtocol? #endif @State var cartResetObserver: Any? + @State var appResumeObserver: Any? #if os(Android) @State var snackbarCenter = SnackbarCenter.shared #else @@ -80,14 +82,26 @@ struct ContentView: View { } } } + .onChange(of: appState.profile.id) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } + .onChange(of: appState.cart.storeId) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } .onAppear { attachCartResetObserverIfNeeded() + attachAppResumeObserverIfNeeded() #if os(iOS) attachSessionExpiredObserverIfNeeded() #endif } .onDisappear { detachCartResetObserver() + detachAppResumeObserver() #if os(iOS) detachSessionExpiredObserver() #endif @@ -179,6 +193,7 @@ struct ContentView: View { // Keep local state when backend refresh fails transiently. } + await refreshFeatureFlags(forceRefresh: false) isBootstrappingSession = false } @@ -189,7 +204,9 @@ struct ContentView: View { object: nil, queue: nil ) { _ in - appState.cart = CartState() + Task { @MainActor in + appState.cart = CartState() + } } } @@ -199,6 +216,26 @@ struct ContentView: View { self.cartResetObserver = nil } + private func attachAppResumeObserverIfNeeded() { + guard appResumeObserver == nil else { return } + appResumeObserver = NotificationCenter.default.addObserver( + forName: .appDidResume, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + guard root == .main else { return } + await refreshFeatureFlags(forceRefresh: true) + } + } + } + + private func detachAppResumeObserver() { + guard let appResumeObserver else { return } + NotificationCenter.default.removeObserver(appResumeObserver) + self.appResumeObserver = nil + } + @MainActor private func hydrateAppState(with customer: CustomerProfile) { appState.profile.id = customer.id @@ -206,6 +243,7 @@ struct ContentView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -299,6 +337,46 @@ struct ContentView: View { scheduleDisableAuthEntryPreparation() } + @MainActor + private func refreshFeatureFlags(forceRefresh: Bool) async { + guard root == .main else { return } + + let subjectType: String + let subjectId: String + if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false { + subjectType = "customer" + subjectId = profileId + } else { + subjectType = "anonymous" + subjectId = "anonymous-device" + } + + var attrs: [String: String] = [:] + let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" { + attrs["addressLabel"] = addressLabel + } + + let context = FeatureControlEvaluationContext( + subjectType: subjectType, + subjectId: subjectId, + storeId: appState.cart.storeId, + attributes: attrs + ) + + let snapshot = await FeatureControlService.shared.evaluate( + context: context, + jwt: appState.session.jwt, + forceRefresh: forceRefresh + ) + appState.featureFlags = snapshot + await FeatureControlService.shared.sendExposureEvents( + snapshot: snapshot, + context: context, + jwt: appState.session.jwt + ) + } + private func scheduleDisableAuthEntryPreparation() { Task { @MainActor in try? await Task.sleep(nanoseconds: 900_000_000) diff --git a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift index 1d672a5..309fc70 100644 --- a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift +++ b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift @@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo /* SKIP @bridge */public func onResume() { logger.debug("onResume") + NotificationCenter.default.post(name: .appDidResume, object: nil) } /* SKIP @bridge */public func onPause() { diff --git a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift index 1fc39db..2c67a63 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift @@ -14,6 +14,17 @@ enum ApiConfig { return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! } + static var featureControlBffURL: URL { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787" + return URL(string: raw) ?? URL(string: "http://localhost:8787")! + } + + static var featureControlEnvironment: String { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production" + let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? "production" : clean + } + // Tokens provided by backend modules static let storeToken = "550e8400-e29b-41d4-a716-446655440008" static let customerToken = "550e8400-e29b-41d4-a716-44665544000a" diff --git a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift new file mode 100644 index 0000000..312a157 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift @@ -0,0 +1,287 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct FeatureControlRawFlag: Codable, Equatable { + let enabled: Bool + let variant: String + let payload: FeatureControlJSONValue? + let reason: String? +} + +enum FeatureControlJSONValue: Codable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: FeatureControlJSONValue]) + case array([FeatureControlJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: FeatureControlJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([FeatureControlJSONValue].self) { + self = .array(value) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +private struct FeatureControlBootstrapRequest: Codable { + struct Context: Codable { + let subjectType: String + let subjectId: String + let storeId: String? + let platform: String + let appVersion: String + let attributes: [String: String] + } + + let environment: String + let keys: [String] + let context: Context +} + +private struct FeatureControlBootstrapResponse: Codable { + let ok: Bool + let source: String? + let configVersion: Int + let evaluatedAt: String? + let flags: [String: FeatureFlagValue] + let raw: [String: FeatureControlRawFlag] +} + +private struct FeatureControlExposureRequest: Codable { + struct Event: Codable { + let featureKey: String + let variant: String + let subjectType: String + let storeId: String? + } + + let events: [Event] +} + +private struct FeatureControlCacheEntry: Codable { + let expiresAtUnixMs: Int64 + let snapshot: FeatureFlagsState +} + +struct FeatureControlEvaluationContext { + let subjectType: String + let subjectId: String + let storeId: String? + let attributes: [String: String] +} + +@MainActor +final class FeatureControlService { + static let shared = FeatureControlService() + + private let session: URLSession + private let cacheTTL: TimeInterval + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private let userDefaults: UserDefaults + private let defaultsPrefix = "feature-control.cache.v1." + + init( + session: URLSession = .shared, + cacheTTL: TimeInterval = 60, + userDefaults: UserDefaults = .standard + ) { + self.session = session + self.cacheTTL = cacheTTL + self.userDefaults = userDefaults + } + + func evaluate( + context: FeatureControlEvaluationContext, + jwt: String?, + forceRefresh: Bool = false + ) async -> FeatureFlagsState { + let key = storageKey(for: context) + if forceRefresh == false, let cached = loadFromCache(storageKey: key) { + return cached + } + + let requestBody = FeatureControlBootstrapRequest( + environment: ApiConfig.featureControlEnvironment, + keys: featureKeys(), + context: .init( + subjectType: context.subjectType, + subjectId: context.subjectId, + storeId: context.storeId, + platform: platformName(), + appVersion: appVersion(), + attributes: context.attributes + ) + ) + + do { + let response = try await performBootstrapRequest(body: requestBody, jwt: jwt) + let snapshot = FeatureFlagsState( + configVersion: response.configVersion, + evaluatedAt: response.evaluatedAt, + source: response.source ?? "live", + values: response.flags, + raw: response.raw + ) + saveToCache(snapshot: snapshot, storageKey: key) + return snapshot + } catch { + if let cached = loadFromCache(storageKey: key) { + return FeatureFlagsState( + configVersion: cached.configVersion, + evaluatedAt: cached.evaluatedAt, + source: "cache_fallback", + values: cached.values, + raw: cached.raw + ) + } + return FeatureFlagsState(source: "defaults") + } + } + + func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async { + guard snapshot.raw.isEmpty == false else { return } + + let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in + let key = entry.key + let value = entry.value + guard value.enabled || value.variant.lowercased() != "off" else { return nil } + return .init( + featureKey: key, + variant: value.variant, + subjectType: context.subjectType, + storeId: context.storeId + ) + } + + guard events.isEmpty == false else { return } + + let batched = Array(events.prefix(100)) + let payload = FeatureControlExposureRequest(events: batched) + guard let body = try? encoder.encode(payload) else { return } + + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure")) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + _ = try? await session.data(for: request) + } + + private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse { + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap")) + request.httpMethod = "POST" + request.httpBody = try encoder.encode(body) + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw NetworkError.invalidResponse + } + + return try decoder.decode(FeatureControlBootstrapResponse.self, from: data) + } + + private func storageKey(for context: FeatureControlEvaluationContext) -> String { + let tokens = [ + ApiConfig.featureControlEnvironment, + context.subjectType, + context.subjectId, + context.storeId ?? "none", + platformName(), + appVersion(), + featureKeys().joined(separator: "|") + ] + let base = tokens.joined(separator: "::") + .lowercased() + .replacingOccurrences(of: " ", with: "_") + return defaultsPrefix + base + } + + private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) { + let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000) + let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot) + guard let data = try? encoder.encode(entry) else { return } + userDefaults.set(data, forKey: storageKey) + } + + private func loadFromCache(storageKey: String) -> FeatureFlagsState? { + guard let data = userDefaults.data(forKey: storageKey), + let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else { + return nil + } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + guard entry.expiresAtUnixMs > now else { + userDefaults.removeObject(forKey: storageKey) + return nil + } + + return entry.snapshot + } + + private func featureKeys() -> [String] { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.checkout_v2,fc.search_ranking_v3" + let items = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + return items.isEmpty ? ["fc.checkout_v2"] : items + } + + private func platformName() -> String { + #if os(Android) + return "android" + #else + return "ios" + #endif + } + + private func appVersion() -> String { + #if canImport(UIKit) || canImport(AppKit) + let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let version, version.isEmpty == false { + return version + } + #endif + return "0.0.0" + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift index 4746314..a25cc72 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -4,4 +4,5 @@ extension Notification.Name { static let sessionExpired = Notification.Name("SessionExpiredNotification") static let cartDidReset = Notification.Name("CartDidResetNotification") static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") + static let appDidResume = Notification.Name("AppDidResumeNotification") } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index b48f28b..6788b70 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -6,10 +6,64 @@ struct AppState { var cart = CartState() var address = AddressState() var favorites = FavoritesState() + var featureFlags = FeatureFlagsState() var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil } +enum FeatureFlagValue: Codable, Equatable { + case boolean(Bool) + case text(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolValue = try? container.decode(Bool.self) { + self = .boolean(boolValue) + return + } + if let stringValue = try? container.decode(String.self) { + self = .text(stringValue) + return + } + if let intValue = try? container.decode(Int.self) { + self = .text(String(intValue)) + return + } + if let doubleValue = try? container.decode(Double.self) { + self = .text(String(doubleValue)) + return + } + self = .text("off") + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .boolean(let value): + try container.encode(value) + case .text(let value): + try container.encode(value) + } + } + + var boolValue: Bool { + switch self { + case .boolean(let value): + return value + case .text(let value): + return value.lowercased() == "on" || value.lowercased() == "true" + } + } +} + +struct FeatureFlagsState: Codable, Equatable { + var configVersion: Int = 0 + var evaluatedAt: String? = nil + var source: String = "default" + var values: [String: FeatureFlagValue] = [:] + var raw: [String: FeatureControlRawFlag] = [:] +} + enum AppModal: String, Identifiable { case addressPicker case filters