flag-bar
0.1.0indexedVendor-neutral, local-first feature-flag system offering type-safe flags, deterministic A/B variant hashing, optional HTTP sync, runtime override drawer, and pluggable persistent override storage.
Vendor-neutral, local-first feature-flag system offering type-safe flags, deterministic A/B variant hashing, optional HTTP sync, runtime override drawer, and pluggable persistent override storage.
A vendor-neutral, local-first feature-flag library for Compose Multiplatform. Type-safe
flag declarations (BoolFlag / IntFlag / StringFlag / EnumFlag / VariantFlag), deterministic
A/B variant assignment via hashing, optional HTTP remote source, and a built-in
FlagOverrideDrawer that lets QA toggle flags + variants at runtime. Persistent overrides via a
pluggable OverrideStorage interface (in-memory by default; wrap multiplatform-settings for
persistence). Pairs with debug-bar — drop in
FlagBarSection(flags) for an integrated debug tab.
Most CMP teams that want feature flags today have 3 choices:
| Existing | Problem |
|---|---|
| LaunchDarkly / ConfigCat / Unleash SDKs | Paid SaaS, vendor lock-in, often Android-only |
| Firebase Remote Config | Android+iOS only, no Desktop/Web KMP support |
| Hand-roll it | What most teams actually do |
The gap: a vendor-neutral, local-first, OSS library where flags live in Kotlin code, evaluate offline, optionally sync from any HTTP endpoint, and ship with a built-in debug-override drawer.
dependencies {
implementation("io.github.nadeemiqbal:flag-bar:0.1.0")
}
(debug-bar is pulled in transitively, so you don't need to add it separately.)
object Flags {
val newCheckout = BoolFlag("new_checkout", default = false)
maxRetries = IntFlag(, default = )
apiBaseUrl = StringFlag(, default = )
theme = EnumFlag(, default = Theme.System, options = Theme.entries)
checkoutVariant = VariantFlag(
key = ,
variants = listOf(, , ),
weights = listOf(, , ),
default = ,
)
}
@Composable
fun App(userId: String) {
val flags = rememberFlagBar(
flags = listOf(
Flags.newCheckout, Flags.maxRetries, Flags.apiBaseUrl,
Flags.theme, Flags.checkoutVariant,
),
userId = userId,
remoteSource = FlagSource.static(mapOf("max_retries" to 5)),
)
// Reactive read — recomposes when flag changes (override or remote)
val showNewCheckout = flags.collectFlagValue(Flags.newCheckout)
variant = flags.collectFlagValue(Flags.checkoutVariant)
(showNewCheckout) NewCheckout(variant) OldCheckout()
}
Imperative reads work too:
class CheckoutVm(private val flags: FlagBar) {
fun process() {
repeat(flags.value(Flags.maxRetries)) { attempt(it) }
when (flags.value(Flags.checkoutVariant)) {
"control" -> oldFlow()
"v1" -> newFlowA()
"v2" -> newFlowB()
}
}
}
For every flag, value resolves in this order:
1. Local override ← set via FlagOverrideDrawer, persisted to OverrideStorage
2. Remote value ← last successful fetch from remoteSource (cached)
3. Default ← what you declared in code
For variant flags:
1. Local override (force a variant from the drawer)
2. Deterministic hash(userId + flagKey) → bucket → variant by weights
3. Default (when userId is null)
Variant assignment is FNV-1a based, fast, no platform deps, deterministic per user.
Use as a debug-bar tab (recommended):
DebugBar(
sections = listOf(
FlagBarSection(flags),
NetworkLogSection(networkStore),
// ...
),
) { MainAppContent() }
Or standalone (inside your own ModalBottomSheet / Dialog):
@Composable
fun MyOverlay() {
Dialog(onDismissRequest = { ... }) {
FlagOverrideDrawer(bar = flags)
}
}
Per-type editors:
fun interface FlagSource { suspend fun : Map<String, Any> }
FlagSource.Empty
FlagSource.static(mapOf( to value))
( client: HttpClient) : FlagSource {
: Map<String, Any> =
client.().body()
}
LaunchDarkly / ConfigCat / GrowthBook / Firebase Remote Config — same pattern, ~10 lines of wrapper.
interface OverrideStorage {
fun getString(key: String): String?
fun setString(key: String, value: String)
fun remove(key: String)
}
Default: OverrideStorage.InMemory() — overrides reset on app restart.
For persistence, wrap multiplatform-settings:
class SettingsStorage(private val settings: Settings) : OverrideStorage {
: String? = settings.getStringOrNull(key)
= settings.putString(key, value)
= settings.remove(key)
}
flags = rememberFlagBar(
flags = AllFlags,
storage = SettingsStorage(Settings()),
)
| Target | Status |
|---|---|
| Android (minSdk 24) | ✅ |
| iOS (x64, arm64, simulatorArm64) | ✅ |
Apache 2.0 — see LICENSE.
| Flag type | Editor |
|---|
BoolFlag | Switch |
IntFlag | Numeric text field |
StringFlag | Free-form text field |
EnumFlag | Dropdown of options |
VariantFlag | Dropdown of variants + shows the hash-assigned default |
| Desktop (JVM 11) | ✅ |
| Web (wasmJs) | ✅ |
Surfaced from shared tags and platforms — no rankings paid for.