From aa52a6517b5069908c13f11c9ddd395dbefa7491 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Thu, 30 Apr 2026 14:29:04 +0300 Subject: [PATCH 1/4] Extract sample shared KMP code into :sample:shared submodule Move sample/src/ into sample/shared/ and register it as :sample:shared instead of :sample. Update :sample:android-app dependency accordingly. Co-Authored-By: Claude Sonnet 4.6 --- sample/android-app/build.gradle.kts | 2 +- sample/shared/build.gradle.kts | 75 ++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../drawable/compose-multiplatform.xml | 44 ++++ .../featured/FeaturedSample.kt | 219 ++++++++++++++++++ .../androidbroadcast/featured/SampleApp.kt | 29 +++ .../featured/SampleFeatureFlags.kt | 84 +++++++ .../featured/SampleViewModel.kt | 99 ++++++++ .../featured/MainViewController.kt | 15 ++ .../androidbroadcast/featured/Main.Desktop.kt | 20 ++ settings.gradle.kts | 2 +- 11 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 sample/shared/build.gradle.kts create mode 100644 sample/shared/src/androidMain/AndroidManifest.xml create mode 100644 sample/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml create mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt create mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt create mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt create mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt create mode 100644 sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt create mode 100644 sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt diff --git a/sample/android-app/build.gradle.kts b/sample/android-app/build.gradle.kts index 7ec60e0..37b81a9 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -40,7 +40,7 @@ android { } dependencies { - implementation(project(":sample")) + implementation(project(":sample:shared")) implementation(project(":featured-debug-ui")) implementation(project(":featured-platform")) implementation(libs.androidx.activity.compose) diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts new file mode 100644 index 0000000..e5f59e6 --- /dev/null +++ b/sample/shared/build.gradle.kts @@ -0,0 +1,75 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.skie) +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "FeaturedSampleApp" + isStatic = true + } + } + + jvm() + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + + implementation(project(":core")) + implementation(project(":featured-registry")) + } + + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + } + } +} + +compose.desktop { + application { + mainClass = "dev.androidbroadcast.featured.MainDesktop" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "dev.androidbroadcast.featured" + packageVersion = "1.0.0" + } + } +} diff --git a/sample/shared/src/androidMain/AndroidManifest.xml b/sample/shared/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/shared/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/sample/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml new file mode 100644 index 0000000..1ffc948 --- /dev/null +++ b/sample/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt new file mode 100644 index 0000000..18399bb --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt @@ -0,0 +1,219 @@ +@file:Suppress("ktlint:standard:function-naming") + +package dev.androidbroadcast.featured + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +/** + * Main sample screen demonstrating `@LocalFlag` and `@RemoteFlag` usage end-to-end. + * + * @param configValues The shared [ConfigValues] instance. + * @param onOpenDebugUi Callback to navigate to [FeatureFlagsDebugScreen]. + * Non-null in debug builds only — the button is absent in release. + * @param modifier Optional [Modifier]. + */ +@Composable +public fun FeaturedSample( + configValues: ConfigValues, + onOpenDebugUi: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val viewModel: SampleViewModel = viewModel { SampleViewModel(configValues) } + val activate by viewModel.flagActive.collectAsStateWithLifecycle() + val buttonColor by viewModel.mainButtonColor.collectAsStateWithLifecycle() + val newFeatureSectionEnabled by viewModel.newFeatureSectionEnabled.collectAsStateWithLifecycle() + val promoBannerEnabled by viewModel.promoBannerEnabled.collectAsStateWithLifecycle() + val checkoutVariant by viewModel.checkoutVariant.collectAsStateWithLifecycle() + + Column( + modifier = + modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Featured Sample", + style = MaterialTheme.typography.headlineSmall, + ) + // Debug UI entry point — only wired in debug builds (onOpenDebugUi is null in release). + if (onOpenDebugUi != null) { + TextButton(onClick = onOpenDebugUi) { + Text("Debug flags") + } + } + } + + // @LocalFlag: main_button_red — button colour driven by a local flag + SectionLabel("@LocalFlag: main_button_red", isRemote = false) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = activate, + onCheckedChange = viewModel::setMainButtonColorFlag, + ) + Text("Enable red button") + } + MainButton( + onClick = { viewModel.setMainButtonColorFlag(!activate) }, + buttonColor = buttonColor, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // @LocalFlag: new_feature_section_enabled — isEnabled guard at a UI entry point. + // When the flag is false the section is absent from the composition tree entirely, + // not merely hidden — this is the recommended guard pattern for navigation entry points. + SectionLabel("@LocalFlag: new_feature_section_enabled (isEnabled guard)", isRemote = false) + if (newFeatureSectionEnabled) { + NewFeatureSection() + } else { + Text( + text = "New feature section disabled by flag.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // @RemoteFlag: promo_banner_enabled — banner visibility driven remotely. + // In production wire a FirebaseConfigValueProvider to ConfigValues.remoteProvider. + SectionLabel("@RemoteFlag: promo_banner_enabled", isRemote = true) + if (promoBannerEnabled) { + PromoBanner() + } else { + Text( + text = "Promo banner off (remote flag default = false).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // @RemoteFlag: checkout_variant — multivariate enum flag resolved remotely. + SectionLabel("@RemoteFlag: checkout_variant (enum)", isRemote = true) + CheckoutVariantDisplay(variant = checkoutVariant) + } +} + +@Composable +public fun MainButton( + onClick: () -> Unit, + buttonColor: SampleViewModel.MainButtonColor, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + colors = + ButtonDefaults.buttonColors( + containerColor = + when (buttonColor) { + SampleViewModel.MainButtonColor.Red -> Color.Red + SampleViewModel.MainButtonColor.Blue -> Color.Blue + }, + ), + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = "Main Button", + color = Color.White, + ) + } +} + +@Composable +private fun SectionLabel( + text: String, + isRemote: Boolean, +) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = if (isRemote) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary, + ) +} + +// Gated by @LocalFlag new_feature_section_enabled. +// When the flag is false this composable is never entered — the section is excluded +// from the composition tree (not merely invisible). +@Composable +private fun NewFeatureSection() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "New Feature", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "This section is visible only when new_feature_section_enabled = true.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +// Shown when @RemoteFlag promo_banner_enabled is true. +@Composable +private fun PromoBanner() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "Special offer! (promo_banner_enabled = true)", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +// Demonstrates multivariate @RemoteFlag: checkout_variant +@Composable +private fun CheckoutVariantDisplay(variant: CheckoutVariant) { + val label = + when (variant) { + CheckoutVariant.LEGACY -> "Legacy checkout (default)" + CheckoutVariant.NEW_SINGLE_PAGE -> "New single-page checkout" + CheckoutVariant.NEW_MULTI_STEP -> "New multi-step checkout" + } + Text( + text = "Active variant: $label", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt new file mode 100644 index 0000000..56a9cc0 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt @@ -0,0 +1,29 @@ +@file:Suppress("ktlint:standard:function-naming") + +package dev.androidbroadcast.featured + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Root composable for the sample application. + * + * [onOpenDebugUi] is non-null in debug builds (wired by the debug source set) + * and null in release builds, so no debug UI entry point is compiled into release. + * + * @param configValues The shared [ConfigValues] instance. + * @param onOpenDebugUi Callback to navigate to the debug UI screen. Null in release builds. + * @param modifier Optional [Modifier] for the root composable. + */ +@Composable +public fun SampleApp( + configValues: ConfigValues, + onOpenDebugUi: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + FeaturedSample( + configValues = configValues, + onOpenDebugUi = onOpenDebugUi, + modifier = modifier, + ) +} diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt new file mode 100644 index 0000000..b75c4f5 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt @@ -0,0 +1,84 @@ +package dev.androidbroadcast.featured + +/** + * Checkout flow variant used to demonstrate multivariate (enum) feature flags. + */ +public enum class CheckoutVariant { + /** The original multi-screen checkout flow. */ + LEGACY, + + /** New single-page checkout (A/B experiment arm A). */ + NEW_SINGLE_PAGE, + + /** New multi-step checkout with progress indicator (A/B experiment arm B). */ + NEW_MULTI_STEP, +} + +/** + * Feature flags for the sample app. + * + * In a real consumer project, Boolean/Int/String flags would be declared in + * `build.gradle.kts` using the Featured Gradle DSL, and the plugin would generate + * typed `ConfigParam` objects and `ConfigValues` extension functions automatically: + * + * ```kotlin + * // build.gradle.kts + * featured { + * localFlags { + * boolean("main_button_red", default = true) { category = "ui" } + * boolean("new_feature_section_enabled", default = true) { category = "ui" } + * } + * remoteFlags { + * boolean("promo_banner_enabled", default = false) { + * description = "Show promotional banner" + * } + * } + * } + * ``` + * + * Enum-typed flags (like [checkoutVariant]) are declared manually as `ConfigParam` + * until enum support is added to the DSL. + * + * The sample module is part of the library's own build and cannot apply the plugin + * to itself, so all flags are declared manually here for demonstration purposes. + */ +public object SampleFeatureFlags { + public val mainButtonRed: ConfigParam = + ConfigParam( + key = "main_button_red", + defaultValue = true, + description = "Enable red color for the main button", + category = "ui", + ) + + public val newFeatureSectionEnabled: ConfigParam = + ConfigParam( + key = "new_feature_section_enabled", + defaultValue = true, + description = "Show the new feature section in the main screen", + category = "ui", + ) + + public val newCheckout: ConfigParam = + ConfigParam( + key = "new_checkout", + defaultValue = false, + description = "Enable the redesigned checkout flow", + ) + + public val promoBannerEnabled: ConfigParam = + ConfigParam( + key = "promo_banner_enabled", + defaultValue = false, + description = "Show a promotional banner on the main screen (remote-controlled)", + category = "promotions", + ) + + public val checkoutVariant: ConfigParam = + ConfigParam( + key = "checkout_variant", + defaultValue = CheckoutVariant.LEGACY, + description = "Controls which checkout flow variant is shown to the user", + category = "checkout", + ) +} diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt new file mode 100644 index 0000000..5955464 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt @@ -0,0 +1,99 @@ +package dev.androidbroadcast.featured + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +public class SampleViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + // --- @LocalFlag: main_button_red --- + + public val flagActive: StateFlow = + configValues + .observe(SampleFeatureFlags.mainButtonRed) + .map { it.value } + .stateIn( + initialValue = SampleFeatureFlags.mainButtonRed.defaultValue, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + public val mainButtonColor: StateFlow = + flagActive + .map { isRed -> + if (isRed) MainButtonColor.Red else MainButtonColor.Blue + }.stateIn( + initialValue = MainButtonColor.Default, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + public fun setMainButtonColorFlag(value: Boolean) { + viewModelScope.launch { + configValues.override(SampleFeatureFlags.mainButtonRed, value) + } + } + + // --- @LocalFlag: new_feature_section_enabled (isEnabled guard pattern) --- + + /** + * Controls visibility of the "New Feature" section. + * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. + */ + public val newFeatureSectionEnabled: StateFlow = + configValues + .observe(SampleFeatureFlags.newFeatureSectionEnabled) + .map { it.value } + .stateIn( + initialValue = SampleFeatureFlags.newFeatureSectionEnabled.defaultValue, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + // --- @RemoteFlag: promo_banner_enabled --- + + /** + * Whether the promotional banner should be shown. + * In production this would be driven by Firebase Remote Config. + */ + public val promoBannerEnabled: StateFlow = + configValues + .observe(SampleFeatureFlags.promoBannerEnabled) + .map { it.value } + .stateIn( + initialValue = SampleFeatureFlags.promoBannerEnabled.defaultValue, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + // --- @RemoteFlag: checkout_variant --- + + /** + * The active checkout variant, driven remotely. + * Demonstrates multivariate enum flags resolved from a remote provider. + */ + public val checkoutVariant: StateFlow = + configValues + .observe(SampleFeatureFlags.checkoutVariant) + .map { it.value } + .stateIn( + initialValue = SampleFeatureFlags.checkoutVariant.defaultValue, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + public sealed interface MainButtonColor { + public data object Red : MainButtonColor + + public data object Blue : MainButtonColor + + public companion object Companion { + public val Default: MainButtonColor = Blue + } + } +} diff --git a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt new file mode 100644 index 0000000..864939a --- /dev/null +++ b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt @@ -0,0 +1,15 @@ +@file:Suppress("RedundantVisibilityModifier", "ktlint:standard:function-naming") + +package dev.androidbroadcast.featured + +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +// ConfigValues is constructed once per UIViewController and passed explicitly. +// In a real app this instance would come from a shared DI container. +public fun MainViewController(): UIViewController { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + return ComposeUIViewController { + FeaturedSample(configValues = configValues) + } +} diff --git a/sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt b/sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt new file mode 100644 index 0000000..3940f58 --- /dev/null +++ b/sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt @@ -0,0 +1,20 @@ +@file:JvmName("MainDesktop") + +package dev.androidbroadcast.featured + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +// ConfigValues is constructed once at the application entry point and passed +// explicitly — the recommended pattern for multi-module apps using DI. +fun main() { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + application { + Window( + onCloseRequest = ::exitApplication, + title = "Featured", + ) { + FeaturedSample(configValues = configValues) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7556a0c..0b25595 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,7 +37,7 @@ plugins { } include(":featured-gradle-plugin") -include(":sample") +include(":sample:shared") include(":sample:android-app") include(":core") include(":featured-compose") From 6f9330825bab8d61cdb5ad4349280332278deea9 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Thu, 30 Apr 2026 15:57:15 +0300 Subject: [PATCH 2/4] Add :sample:desktop module, remove legacy :sample module - Add standalone :sample:desktop JVM application module - Move Main.Desktop.kt from :sample:shared jvmMain to :sample:desktop - Remove compose.desktop and jvmMain from :sample:shared (library, not app) - Delete legacy :sample module (sample/src/, sample/build.gradle.kts) - Refactor SampleViewModel: extract observeValue() to reduce stateIn boilerplate - Fix MainViewController to use SampleApp instead of FeaturedSample directly Co-Authored-By: Claude Sonnet 4.6 --- sample/build.gradle.kts | 75 ------ sample/desktop/build.gradle.kts | 33 +++ .../androidbroadcast/featured/Main.Desktop.kt | 2 +- sample/shared/build.gradle.kts | 18 -- .../featured/SampleViewModel.kt | 25 +- .../featured/MainViewController.kt | 2 +- sample/src/androidMain/AndroidManifest.xml | 2 - .../drawable/compose-multiplatform.xml | 44 ---- .../featured/FeaturedSample.kt | 219 ------------------ .../androidbroadcast/featured/SampleApp.kt | 29 --- .../featured/SampleFeatureFlags.kt | 84 ------- .../featured/SampleViewModel.kt | 99 -------- .../featured/MainViewController.kt | 15 -- .../androidbroadcast/featured/Main.Desktop.kt | 20 -- settings.gradle.kts | 1 + 15 files changed, 49 insertions(+), 619 deletions(-) delete mode 100644 sample/build.gradle.kts create mode 100644 sample/desktop/build.gradle.kts rename sample/{shared => desktop}/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt (90%) delete mode 100644 sample/src/androidMain/AndroidManifest.xml delete mode 100644 sample/src/commonMain/composeResources/drawable/compose-multiplatform.xml delete mode 100644 sample/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt delete mode 100644 sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt delete mode 100644 sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt delete mode 100644 sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt delete mode 100644 sample/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt delete mode 100644 sample/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts deleted file mode 100644 index e5f59e6..0000000 --- a/sample/build.gradle.kts +++ /dev/null @@ -1,75 +0,0 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidKmpLibrary) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.skie) -} - -kotlin { - jvmToolchain(21) - - android { - namespace = "dev.androidbroadcast.featured.sample" - compileSdk = - libs.versions.android.compileSdk - .get() - .toInt() - minSdk = - libs.versions.android.minSdk - .get() - .toInt() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - } - } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "FeaturedSampleApp" - isStatic = true - } - } - - jvm() - - sourceSets { - commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) - implementation(libs.androidx.lifecycle.viewmodelCompose) - implementation(libs.androidx.lifecycle.runtimeCompose) - - implementation(project(":core")) - implementation(project(":featured-registry")) - } - - jvmMain.dependencies { - implementation(compose.desktop.currentOs) - implementation(libs.kotlinx.coroutinesSwing) - } - } -} - -compose.desktop { - application { - mainClass = "dev.androidbroadcast.featured.MainDesktop" - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "dev.androidbroadcast.featured" - packageVersion = "1.0.0" - } - } -} diff --git a/sample/desktop/build.gradle.kts b/sample/desktop/build.gradle.kts new file mode 100644 index 0000000..f512a75 --- /dev/null +++ b/sample/desktop/build.gradle.kts @@ -0,0 +1,33 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + jvmToolchain(21) + jvm() + + sourceSets { + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + implementation(project(":sample:shared")) + } + } +} + +compose.desktop { + application { + mainClass = "dev.androidbroadcast.featured.MainDesktop" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "dev.androidbroadcast.featured" + // Matches versionName in :sample:android-app + packageVersion = "1.0.0" + } + } +} diff --git a/sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt b/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt similarity index 90% rename from sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt rename to sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt index 3940f58..0f1ad0d 100644 --- a/sample/shared/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt +++ b/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt @@ -14,7 +14,7 @@ fun main() { onCloseRequest = ::exitApplication, title = "Featured", ) { - FeaturedSample(configValues = configValues) + SampleApp(configValues = configValues) } } } diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index e5f59e6..4f81ef5 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -54,22 +53,5 @@ kotlin { implementation(project(":core")) implementation(project(":featured-registry")) } - - jvmMain.dependencies { - implementation(compose.desktop.currentOs) - implementation(libs.kotlinx.coroutinesSwing) - } - } -} - -compose.desktop { - application { - mainClass = "dev.androidbroadcast.featured.MainDesktop" - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "dev.androidbroadcast.featured" - packageVersion = "1.0.0" - } } } diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt index 5955464..3833dbe 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt @@ -2,6 +2,7 @@ package dev.androidbroadcast.featured import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -14,9 +15,7 @@ public class SampleViewModel( // --- @LocalFlag: main_button_red --- public val flagActive: StateFlow = - configValues - .observe(SampleFeatureFlags.mainButtonRed) - .map { it.value } + configValues.observeValue(SampleFeatureFlags.mainButtonRed) .stateIn( initialValue = SampleFeatureFlags.mainButtonRed.defaultValue, scope = viewModelScope, @@ -46,9 +45,7 @@ public class SampleViewModel( * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. */ public val newFeatureSectionEnabled: StateFlow = - configValues - .observe(SampleFeatureFlags.newFeatureSectionEnabled) - .map { it.value } + configValues.observeValue(SampleFeatureFlags.newFeatureSectionEnabled) .stateIn( initialValue = SampleFeatureFlags.newFeatureSectionEnabled.defaultValue, scope = viewModelScope, @@ -62,9 +59,7 @@ public class SampleViewModel( * In production this would be driven by Firebase Remote Config. */ public val promoBannerEnabled: StateFlow = - configValues - .observe(SampleFeatureFlags.promoBannerEnabled) - .map { it.value } + configValues.observeValue(SampleFeatureFlags.promoBannerEnabled) .stateIn( initialValue = SampleFeatureFlags.promoBannerEnabled.defaultValue, scope = viewModelScope, @@ -78,9 +73,7 @@ public class SampleViewModel( * Demonstrates multivariate enum flags resolved from a remote provider. */ public val checkoutVariant: StateFlow = - configValues - .observe(SampleFeatureFlags.checkoutVariant) - .map { it.value } + configValues.observeValue(SampleFeatureFlags.checkoutVariant) .stateIn( initialValue = SampleFeatureFlags.checkoutVariant.defaultValue, scope = viewModelScope, @@ -97,3 +90,11 @@ public class SampleViewModel( } } } + +/** + * Extracts the raw [T] value from a [ConfigValue] stream. + * Shorthand for `.observe(param).map { it.value }`, used when [stateIn] is chained + * directly without additional transformation. + */ +private fun ConfigValues.observeValue(param: ConfigParam): Flow = + observe(param).map { it.value } diff --git a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt index 864939a..254a6b0 100644 --- a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt +++ b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt @@ -10,6 +10,6 @@ import platform.UIKit.UIViewController public fun MainViewController(): UIViewController { val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) return ComposeUIViewController { - FeaturedSample(configValues = configValues) + SampleApp(configValues = configValues) } } diff --git a/sample/src/androidMain/AndroidManifest.xml b/sample/src/androidMain/AndroidManifest.xml deleted file mode 100644 index b2d3ea1..0000000 --- a/sample/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/sample/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/sample/src/commonMain/composeResources/drawable/compose-multiplatform.xml deleted file mode 100644 index 1ffc948..0000000 --- a/sample/src/commonMain/composeResources/drawable/compose-multiplatform.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt b/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt deleted file mode 100644 index 18399bb..0000000 --- a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt +++ /dev/null @@ -1,219 +0,0 @@ -@file:Suppress("ktlint:standard:function-naming") - -package dev.androidbroadcast.featured - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel - -/** - * Main sample screen demonstrating `@LocalFlag` and `@RemoteFlag` usage end-to-end. - * - * @param configValues The shared [ConfigValues] instance. - * @param onOpenDebugUi Callback to navigate to [FeatureFlagsDebugScreen]. - * Non-null in debug builds only — the button is absent in release. - * @param modifier Optional [Modifier]. - */ -@Composable -public fun FeaturedSample( - configValues: ConfigValues, - onOpenDebugUi: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - val viewModel: SampleViewModel = viewModel { SampleViewModel(configValues) } - val activate by viewModel.flagActive.collectAsStateWithLifecycle() - val buttonColor by viewModel.mainButtonColor.collectAsStateWithLifecycle() - val newFeatureSectionEnabled by viewModel.newFeatureSectionEnabled.collectAsStateWithLifecycle() - val promoBannerEnabled by viewModel.promoBannerEnabled.collectAsStateWithLifecycle() - val checkoutVariant by viewModel.checkoutVariant.collectAsStateWithLifecycle() - - Column( - modifier = - modifier - .padding(16.dp) - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Featured Sample", - style = MaterialTheme.typography.headlineSmall, - ) - // Debug UI entry point — only wired in debug builds (onOpenDebugUi is null in release). - if (onOpenDebugUi != null) { - TextButton(onClick = onOpenDebugUi) { - Text("Debug flags") - } - } - } - - // @LocalFlag: main_button_red — button colour driven by a local flag - SectionLabel("@LocalFlag: main_button_red", isRemote = false) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Checkbox( - checked = activate, - onCheckedChange = viewModel::setMainButtonColorFlag, - ) - Text("Enable red button") - } - MainButton( - onClick = { viewModel.setMainButtonColorFlag(!activate) }, - buttonColor = buttonColor, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // @LocalFlag: new_feature_section_enabled — isEnabled guard at a UI entry point. - // When the flag is false the section is absent from the composition tree entirely, - // not merely hidden — this is the recommended guard pattern for navigation entry points. - SectionLabel("@LocalFlag: new_feature_section_enabled (isEnabled guard)", isRemote = false) - if (newFeatureSectionEnabled) { - NewFeatureSection() - } else { - Text( - text = "New feature section disabled by flag.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - // @RemoteFlag: promo_banner_enabled — banner visibility driven remotely. - // In production wire a FirebaseConfigValueProvider to ConfigValues.remoteProvider. - SectionLabel("@RemoteFlag: promo_banner_enabled", isRemote = true) - if (promoBannerEnabled) { - PromoBanner() - } else { - Text( - text = "Promo banner off (remote flag default = false).", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - // @RemoteFlag: checkout_variant — multivariate enum flag resolved remotely. - SectionLabel("@RemoteFlag: checkout_variant (enum)", isRemote = true) - CheckoutVariantDisplay(variant = checkoutVariant) - } -} - -@Composable -public fun MainButton( - onClick: () -> Unit, - buttonColor: SampleViewModel.MainButtonColor, - modifier: Modifier = Modifier, -) { - Button( - onClick = onClick, - colors = - ButtonDefaults.buttonColors( - containerColor = - when (buttonColor) { - SampleViewModel.MainButtonColor.Red -> Color.Red - SampleViewModel.MainButtonColor.Blue -> Color.Blue - }, - ), - modifier = modifier.fillMaxWidth(), - ) { - Text( - text = "Main Button", - color = Color.White, - ) - } -} - -@Composable -private fun SectionLabel( - text: String, - isRemote: Boolean, -) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = if (isRemote) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary, - ) -} - -// Gated by @LocalFlag new_feature_section_enabled. -// When the flag is false this composable is never entered — the section is excluded -// from the composition tree (not merely invisible). -@Composable -private fun NewFeatureSection() { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "New Feature", - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = "This section is visible only when new_feature_section_enabled = true.", - style = MaterialTheme.typography.bodySmall, - ) - } - } -} - -// Shown when @RemoteFlag promo_banner_enabled is true. -@Composable -private fun PromoBanner() { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), - ) { - Text( - text = "Special offer! (promo_banner_enabled = true)", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - ) - } -} - -// Demonstrates multivariate @RemoteFlag: checkout_variant -@Composable -private fun CheckoutVariantDisplay(variant: CheckoutVariant) { - val label = - when (variant) { - CheckoutVariant.LEGACY -> "Legacy checkout (default)" - CheckoutVariant.NEW_SINGLE_PAGE -> "New single-page checkout" - CheckoutVariant.NEW_MULTI_STEP -> "New multi-step checkout" - } - Text( - text = "Active variant: $label", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} diff --git a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt b/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt deleted file mode 100644 index 56a9cc0..0000000 --- a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt +++ /dev/null @@ -1,29 +0,0 @@ -@file:Suppress("ktlint:standard:function-naming") - -package dev.androidbroadcast.featured - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Root composable for the sample application. - * - * [onOpenDebugUi] is non-null in debug builds (wired by the debug source set) - * and null in release builds, so no debug UI entry point is compiled into release. - * - * @param configValues The shared [ConfigValues] instance. - * @param onOpenDebugUi Callback to navigate to the debug UI screen. Null in release builds. - * @param modifier Optional [Modifier] for the root composable. - */ -@Composable -public fun SampleApp( - configValues: ConfigValues, - onOpenDebugUi: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - FeaturedSample( - configValues = configValues, - onOpenDebugUi = onOpenDebugUi, - modifier = modifier, - ) -} diff --git a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt b/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt deleted file mode 100644 index b75c4f5..0000000 --- a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.androidbroadcast.featured - -/** - * Checkout flow variant used to demonstrate multivariate (enum) feature flags. - */ -public enum class CheckoutVariant { - /** The original multi-screen checkout flow. */ - LEGACY, - - /** New single-page checkout (A/B experiment arm A). */ - NEW_SINGLE_PAGE, - - /** New multi-step checkout with progress indicator (A/B experiment arm B). */ - NEW_MULTI_STEP, -} - -/** - * Feature flags for the sample app. - * - * In a real consumer project, Boolean/Int/String flags would be declared in - * `build.gradle.kts` using the Featured Gradle DSL, and the plugin would generate - * typed `ConfigParam` objects and `ConfigValues` extension functions automatically: - * - * ```kotlin - * // build.gradle.kts - * featured { - * localFlags { - * boolean("main_button_red", default = true) { category = "ui" } - * boolean("new_feature_section_enabled", default = true) { category = "ui" } - * } - * remoteFlags { - * boolean("promo_banner_enabled", default = false) { - * description = "Show promotional banner" - * } - * } - * } - * ``` - * - * Enum-typed flags (like [checkoutVariant]) are declared manually as `ConfigParam` - * until enum support is added to the DSL. - * - * The sample module is part of the library's own build and cannot apply the plugin - * to itself, so all flags are declared manually here for demonstration purposes. - */ -public object SampleFeatureFlags { - public val mainButtonRed: ConfigParam = - ConfigParam( - key = "main_button_red", - defaultValue = true, - description = "Enable red color for the main button", - category = "ui", - ) - - public val newFeatureSectionEnabled: ConfigParam = - ConfigParam( - key = "new_feature_section_enabled", - defaultValue = true, - description = "Show the new feature section in the main screen", - category = "ui", - ) - - public val newCheckout: ConfigParam = - ConfigParam( - key = "new_checkout", - defaultValue = false, - description = "Enable the redesigned checkout flow", - ) - - public val promoBannerEnabled: ConfigParam = - ConfigParam( - key = "promo_banner_enabled", - defaultValue = false, - description = "Show a promotional banner on the main screen (remote-controlled)", - category = "promotions", - ) - - public val checkoutVariant: ConfigParam = - ConfigParam( - key = "checkout_variant", - defaultValue = CheckoutVariant.LEGACY, - description = "Controls which checkout flow variant is shown to the user", - category = "checkout", - ) -} diff --git a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt deleted file mode 100644 index 5955464..0000000 --- a/sample/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -package dev.androidbroadcast.featured - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -public class SampleViewModel( - private val configValues: ConfigValues, -) : ViewModel() { - // --- @LocalFlag: main_button_red --- - - public val flagActive: StateFlow = - configValues - .observe(SampleFeatureFlags.mainButtonRed) - .map { it.value } - .stateIn( - initialValue = SampleFeatureFlags.mainButtonRed.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - public val mainButtonColor: StateFlow = - flagActive - .map { isRed -> - if (isRed) MainButtonColor.Red else MainButtonColor.Blue - }.stateIn( - initialValue = MainButtonColor.Default, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - public fun setMainButtonColorFlag(value: Boolean) { - viewModelScope.launch { - configValues.override(SampleFeatureFlags.mainButtonRed, value) - } - } - - // --- @LocalFlag: new_feature_section_enabled (isEnabled guard pattern) --- - - /** - * Controls visibility of the "New Feature" section. - * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. - */ - public val newFeatureSectionEnabled: StateFlow = - configValues - .observe(SampleFeatureFlags.newFeatureSectionEnabled) - .map { it.value } - .stateIn( - initialValue = SampleFeatureFlags.newFeatureSectionEnabled.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - // --- @RemoteFlag: promo_banner_enabled --- - - /** - * Whether the promotional banner should be shown. - * In production this would be driven by Firebase Remote Config. - */ - public val promoBannerEnabled: StateFlow = - configValues - .observe(SampleFeatureFlags.promoBannerEnabled) - .map { it.value } - .stateIn( - initialValue = SampleFeatureFlags.promoBannerEnabled.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - // --- @RemoteFlag: checkout_variant --- - - /** - * The active checkout variant, driven remotely. - * Demonstrates multivariate enum flags resolved from a remote provider. - */ - public val checkoutVariant: StateFlow = - configValues - .observe(SampleFeatureFlags.checkoutVariant) - .map { it.value } - .stateIn( - initialValue = SampleFeatureFlags.checkoutVariant.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - public sealed interface MainButtonColor { - public data object Red : MainButtonColor - - public data object Blue : MainButtonColor - - public companion object Companion { - public val Default: MainButtonColor = Blue - } - } -} diff --git a/sample/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt b/sample/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt deleted file mode 100644 index 864939a..0000000 --- a/sample/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt +++ /dev/null @@ -1,15 +0,0 @@ -@file:Suppress("RedundantVisibilityModifier", "ktlint:standard:function-naming") - -package dev.androidbroadcast.featured - -import androidx.compose.ui.window.ComposeUIViewController -import platform.UIKit.UIViewController - -// ConfigValues is constructed once per UIViewController and passed explicitly. -// In a real app this instance would come from a shared DI container. -public fun MainViewController(): UIViewController { - val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) - return ComposeUIViewController { - FeaturedSample(configValues = configValues) - } -} diff --git a/sample/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt b/sample/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt deleted file mode 100644 index 3940f58..0000000 --- a/sample/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt +++ /dev/null @@ -1,20 +0,0 @@ -@file:JvmName("MainDesktop") - -package dev.androidbroadcast.featured - -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application - -// ConfigValues is constructed once at the application entry point and passed -// explicitly — the recommended pattern for multi-module apps using DI. -fun main() { - val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) - application { - Window( - onCloseRequest = ::exitApplication, - title = "Featured", - ) { - FeaturedSample(configValues = configValues) - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0b25595..201871b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ plugins { include(":featured-gradle-plugin") include(":sample:shared") include(":sample:android-app") +include(":sample:desktop") include(":core") include(":featured-compose") include(":featured-registry") From 08764f3a64a4ab4e5ef375f2d9006fbe99659799 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Thu, 30 Apr 2026 17:01:15 +0300 Subject: [PATCH 3/4] =?UTF-8?q?Fix=20CI=20failures=20after=20:sample=20?= =?UTF-8?q?=E2=86=92=20:sample:shared=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add T : Any bound to observeValue extension (ConfigValues.observe requires it) - Reformat method chains in SampleViewModel to satisfy spotless - Update iosApp.xcodeproj: Gradle task and framework paths now point to :sample:shared --- iosApp/iosApp.xcodeproj/project.pbxproj | 6 +++--- .../featured/SampleViewModel.kt | 20 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 75c5bee..c2cd64e 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -152,7 +152,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nif [ -z \"$JAVA_HOME\" ] || [ ! -d \"$JAVA_HOME\" ]; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 21 2>/dev/null || /usr/libexec/java_home 2>/dev/null)\nfi\ncd \"$SRCROOT/..\"\n./gradlew :sample:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nif [ -z \"$JAVA_HOME\" ] || [ ! -d \"$JAVA_HOME\" ]; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 21 2>/dev/null || /usr/libexec/java_home 2>/dev/null)\nfi\ncd \"$SRCROOT/..\"\n./gradlew :sample:shared:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -300,7 +300,7 @@ ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", - "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/../sample/shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -333,7 +333,7 @@ ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", - "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/../sample/shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt index 3833dbe..e248f54 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt @@ -15,7 +15,8 @@ public class SampleViewModel( // --- @LocalFlag: main_button_red --- public val flagActive: StateFlow = - configValues.observeValue(SampleFeatureFlags.mainButtonRed) + configValues + .observeValue(SampleFeatureFlags.mainButtonRed) .stateIn( initialValue = SampleFeatureFlags.mainButtonRed.defaultValue, scope = viewModelScope, @@ -45,7 +46,8 @@ public class SampleViewModel( * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. */ public val newFeatureSectionEnabled: StateFlow = - configValues.observeValue(SampleFeatureFlags.newFeatureSectionEnabled) + configValues + .observeValue(SampleFeatureFlags.newFeatureSectionEnabled) .stateIn( initialValue = SampleFeatureFlags.newFeatureSectionEnabled.defaultValue, scope = viewModelScope, @@ -59,7 +61,8 @@ public class SampleViewModel( * In production this would be driven by Firebase Remote Config. */ public val promoBannerEnabled: StateFlow = - configValues.observeValue(SampleFeatureFlags.promoBannerEnabled) + configValues + .observeValue(SampleFeatureFlags.promoBannerEnabled) .stateIn( initialValue = SampleFeatureFlags.promoBannerEnabled.defaultValue, scope = viewModelScope, @@ -73,7 +76,8 @@ public class SampleViewModel( * Demonstrates multivariate enum flags resolved from a remote provider. */ public val checkoutVariant: StateFlow = - configValues.observeValue(SampleFeatureFlags.checkoutVariant) + configValues + .observeValue(SampleFeatureFlags.checkoutVariant) .stateIn( initialValue = SampleFeatureFlags.checkoutVariant.defaultValue, scope = viewModelScope, @@ -91,10 +95,4 @@ public class SampleViewModel( } } -/** - * Extracts the raw [T] value from a [ConfigValue] stream. - * Shorthand for `.observe(param).map { it.value }`, used when [stateIn] is chained - * directly without additional transformation. - */ -private fun ConfigValues.observeValue(param: ConfigParam): Flow = - observe(param).map { it.value } +private fun ConfigValues.observeValue(param: ConfigParam): Flow = observe(param).map { it.value } From a8f4cf849d2c7d03e2feb0caef9ee84cda9070fe Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Thu, 30 Apr 2026 17:04:48 +0300 Subject: [PATCH 4/4] Use library's asStateFlow instead of hand-rolled observeValue Removes private observeValue extension that duplicated ConfigValuesExtensions. Four stateIn() blocks replaced with asStateFlow() from the same file. --- .../featured/SampleViewModel.kt | 43 ++----------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt index e248f54..420da49 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt @@ -2,7 +2,6 @@ package dev.androidbroadcast.featured import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -12,16 +11,8 @@ import kotlinx.coroutines.launch public class SampleViewModel( private val configValues: ConfigValues, ) : ViewModel() { - // --- @LocalFlag: main_button_red --- - public val flagActive: StateFlow = - configValues - .observeValue(SampleFeatureFlags.mainButtonRed) - .stateIn( - initialValue = SampleFeatureFlags.mainButtonRed.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) + configValues.asStateFlow(SampleFeatureFlags.mainButtonRed, viewModelScope) public val mainButtonColor: StateFlow = flagActive @@ -39,50 +30,26 @@ public class SampleViewModel( } } - // --- @LocalFlag: new_feature_section_enabled (isEnabled guard pattern) --- - /** * Controls visibility of the "New Feature" section. * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. */ public val newFeatureSectionEnabled: StateFlow = - configValues - .observeValue(SampleFeatureFlags.newFeatureSectionEnabled) - .stateIn( - initialValue = SampleFeatureFlags.newFeatureSectionEnabled.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - // --- @RemoteFlag: promo_banner_enabled --- + configValues.asStateFlow(SampleFeatureFlags.newFeatureSectionEnabled, viewModelScope) /** * Whether the promotional banner should be shown. * In production this would be driven by Firebase Remote Config. */ public val promoBannerEnabled: StateFlow = - configValues - .observeValue(SampleFeatureFlags.promoBannerEnabled) - .stateIn( - initialValue = SampleFeatureFlags.promoBannerEnabled.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - // --- @RemoteFlag: checkout_variant --- + configValues.asStateFlow(SampleFeatureFlags.promoBannerEnabled, viewModelScope) /** * The active checkout variant, driven remotely. * Demonstrates multivariate enum flags resolved from a remote provider. */ public val checkoutVariant: StateFlow = - configValues - .observeValue(SampleFeatureFlags.checkoutVariant) - .stateIn( - initialValue = SampleFeatureFlags.checkoutVariant.defaultValue, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) + configValues.asStateFlow(SampleFeatureFlags.checkoutVariant, viewModelScope) public sealed interface MainButtonColor { public data object Red : MainButtonColor @@ -94,5 +61,3 @@ public class SampleViewModel( } } } - -private fun ConfigValues.observeValue(param: ConfigParam): Flow = observe(param).map { it.value }