feat(app): integrate Atomenta Feature Control bootstrap, cache and resume refresh
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@@ -15,6 +16,7 @@ struct ContentView: View {
|
|||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
#endif
|
#endif
|
||||||
@State var cartResetObserver: Any?
|
@State var cartResetObserver: Any?
|
||||||
|
@State var appResumeObserver: Any?
|
||||||
#if os(Android)
|
#if os(Android)
|
||||||
@State var snackbarCenter = SnackbarCenter.shared
|
@State var snackbarCenter = SnackbarCenter.shared
|
||||||
#else
|
#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 {
|
.onAppear {
|
||||||
attachCartResetObserverIfNeeded()
|
attachCartResetObserverIfNeeded()
|
||||||
|
attachAppResumeObserverIfNeeded()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
attachSessionExpiredObserverIfNeeded()
|
attachSessionExpiredObserverIfNeeded()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
detachCartResetObserver()
|
detachCartResetObserver()
|
||||||
|
detachAppResumeObserver()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
detachSessionExpiredObserver()
|
detachSessionExpiredObserver()
|
||||||
#endif
|
#endif
|
||||||
@@ -179,6 +193,7 @@ struct ContentView: View {
|
|||||||
// Keep local state when backend refresh fails transiently.
|
// Keep local state when backend refresh fails transiently.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshFeatureFlags(forceRefresh: false)
|
||||||
isBootstrappingSession = false
|
isBootstrappingSession = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +204,9 @@ struct ContentView: View {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: nil
|
queue: nil
|
||||||
) { _ in
|
) { _ in
|
||||||
appState.cart = CartState()
|
Task { @MainActor in
|
||||||
|
appState.cart = CartState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +216,26 @@ struct ContentView: View {
|
|||||||
self.cartResetObserver = nil
|
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
|
@MainActor
|
||||||
private func hydrateAppState(with customer: CustomerProfile) {
|
private func hydrateAppState(with customer: CustomerProfile) {
|
||||||
appState.profile.id = customer.id
|
appState.profile.id = customer.id
|
||||||
@@ -206,6 +243,7 @@ struct ContentView: View {
|
|||||||
appState.profile.email = customer.email
|
appState.profile.email = customer.email
|
||||||
appState.profile.phone = customer.phoneNumber ?? ""
|
appState.profile.phone = customer.phoneNumber ?? ""
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
)
|
)
|
||||||
@@ -299,6 +337,46 @@ struct ContentView: View {
|
|||||||
scheduleDisableAuthEntryPreparation()
|
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() {
|
private func scheduleDisableAuthEntryPreparation() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo
|
|||||||
|
|
||||||
/* SKIP @bridge */public func onResume() {
|
/* SKIP @bridge */public func onResume() {
|
||||||
logger.debug("onResume")
|
logger.debug("onResume")
|
||||||
|
NotificationCenter.default.post(name: .appDidResume, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SKIP @bridge */public func onPause() {
|
/* SKIP @bridge */public func onPause() {
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ enum ApiConfig {
|
|||||||
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
|
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
|
// Tokens provided by backend modules
|
||||||
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
|
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
|
||||||
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
|
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@ extension Notification.Name {
|
|||||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||||
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||||
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
||||||
|
static let appDidResume = Notification.Name("AppDidResumeNotification")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,64 @@ struct AppState {
|
|||||||
var cart = CartState()
|
var cart = CartState()
|
||||||
var address = AddressState()
|
var address = AddressState()
|
||||||
var favorites = FavoritesState()
|
var favorites = FavoritesState()
|
||||||
|
var featureFlags = FeatureFlagsState()
|
||||||
var homeFilters = HomeFiltersState()
|
var homeFilters = HomeFiltersState()
|
||||||
var activeModal: AppModal? = nil
|
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 {
|
enum AppModal: String, Identifiable {
|
||||||
case addressPicker
|
case addressPicker
|
||||||
case filters
|
case filters
|
||||||
|
|||||||
Reference in New Issue
Block a user