diff --git a/config/quality/detekt/compose-config.yml b/config/quality/detekt/compose-config.yml index 04dbff05..adcd0b38 100644 --- a/config/quality/detekt/compose-config.yml +++ b/config/quality/detekt/compose-config.yml @@ -12,7 +12,7 @@ Compose: CompositionLocalAllowlist: active: true # -- You can optionally define a list of CompositionLocals that are allowed here - allowedCompositionLocals: LocalLoading, LocalSharedTransitionScope, LocalAnimatedVisibilityScope, LocalWindowSizeClass + allowedCompositionLocals: LocalLoading, LocalSharedTransitionScope, LocalAnimatedVisibilityScope, LocalWindowSizeClass, LocalFeatureHandler CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/config/quality/detekt/ktlint-config.yml b/config/quality/detekt/ktlint-config.yml index bbed04fe..19158ae3 100644 --- a/config/quality/detekt/ktlint-config.yml +++ b/config/quality/detekt/ktlint-config.yml @@ -85,7 +85,7 @@ ktlint: autoCorrect: true indentSize: 4 Filename: - active: true + active: false FinalNewline: active: true autoCorrect: true diff --git a/devview-featureflip/README.md b/devview-featureflip/README.md new file mode 100644 index 00000000..5e370f4c --- /dev/null +++ b/devview-featureflip/README.md @@ -0,0 +1,272 @@ +# DevView FeatureFlip Module + +A Kotlin Multiplatform library for managing feature flags (feature toggles) with a built-in UI for toggling and managing features at runtime. + +## Features + +- ✨ **Two Feature Types** + - **Local Features**: Simple on/off flags stored on the device + - **Remote Features**: Flags controlled by remote configuration with local override capability + +- 💾 **Persistent Storage**: Automatic state persistence using DataStore (works on Android and iOS) + +- 🎨 **Built-in UI**: Ready-to-use Compose Multiplatform UI components + - Search functionality + - Filter by feature type (Local/Remote) and state (On/Off) + - Intuitive switches and tri-state controls + - Material Design 3 styling + +- 🔧 **Type-Safe API**: Leverages Kotlin's type system for compile-time safety + +- 📱 **Multiplatform**: Supports Android and iOS + +## Installation + +Add the dependency to your `build.gradle.kts`: + +```kotlin +dependencies { + implementation(projects.devviewFeatureflip) +} +``` + +## Quick Start + +### Defining Features + +```kotlin +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.FeatureState + +// Local feature - simple on/off toggle +val darkMode = Feature.LocalFeature( + name = "dark_mode", + description = "Enable dark theme", + isEnabled = false +) + +// Remote feature - with remote config and local override +val newCheckout = Feature.RemoteFeature( + name = "new_checkout_flow", + description = "Enable the redesigned checkout experience", + defaultRemoteValue = true, // Value from remote config + state = FeatureState.REMOTE // Currently using remote value +) +``` + +### Using the UI + +```kotlin +import androidx.compose.runtime.CompositionLocalProvider +import com.worldline.devview.featureflip.FeatureFlipScreen +import com.worldline.devview.featureflip.LocalFeatures + +@Composable +fun MyApp() { + val features = remember { + listOf( + Feature.LocalFeature( + name = "dark_mode", + description = "Enable dark theme", + isEnabled = false + ), + Feature.RemoteFeature( + name = "new_feature", + description = "Our awesome new feature", + defaultRemoteValue = true, + state = FeatureState.REMOTE + ) + ) + } + + CompositionLocalProvider(LocalFeatures provides features) { + FeatureFlipScreen( + onStateChange = { featureName, newState -> + // Handle state change + println("Feature '$featureName' changed to $newState") + } + ) + } +} +``` + +## Feature States + +Remote features support three states: + +- **`FeatureState.REMOTE`**: Use the default value from remote configuration +- **`FeatureState.LOCAL_ON`**: Override to force the feature ON locally +- **`FeatureState.LOCAL_OFF`**: Override to force the feature OFF locally + +## Managing Feature State Programmatically + +```kotlin +import com.worldline.devview.featureflip.model.FeatureHandler +import com.worldline.devview.featureflip.model.createDataStore + +// Create a DataStore instance +val dataStore = createDataStore { "/path/to/datastore" } + +// Create a handler +val handler = FeatureHandler(dataStore) + +// Add features +handler.addFeatures(features) + +// Check if a feature is enabled +handler.isFeatureEnabled("dark_mode") + .collect { isEnabled -> + println("Dark mode is ${if (isEnabled) "on" else "off"}") + } + +// Change a feature's state +handler.setFeatureState("new_feature", FeatureState.LOCAL_ON) + +// Get all features with current state +handler.getFeatures() + .collect { features -> + features.forEach { feature -> + println("${feature.name}: ${feature.isEnabled}") + } + } +``` + +## Architecture + +### Data Models + +``` +Feature (sealed class) +├── LocalFeature +│ ├── name: String +│ ├── description: String? +│ └── isEnabled: Boolean +│ +└── RemoteFeature + ├── name: String + ├── description: String? + ├── defaultRemoteValue: Boolean + └── state: FeatureState +``` + +### Feature State Flow + +1. **Remote Features**: + - Start with a `defaultRemoteValue` from your remote config service + - Can be overridden locally using `FeatureState.LOCAL_ON` or `LOCAL_OFF` + - Reset to remote value with `FeatureState.REMOTE` + +2. **Local Features**: + - Simple boolean enabled/disabled state + - Stored locally using DataStore + +### Persistence + +The module uses Jetpack DataStore (preferences) for persisting feature states: +- **Android**: Stored in the app's files directory +- **iOS**: Stored in the app's document directory + +## UI Components + +### FeatureFlipScreen + +The main screen component that displays all features with search and filter capabilities. + +**Features:** +- Search by feature name +- Filter by type (Local/Remote) +- Filter by state (On/Off) +- Automatic UI updates when states change + +### Feature Controls + +- **Local Features**: Display a standard Material Switch +- **Remote Features**: Display a tri-state segmented button: + - 🌐 Remote (cloud icon) - Use remote configuration + - ❌ Off (cancel icon) - Force feature off + - ✅ On (check icon) - Force feature on + +## API Reference + +### Core Types + +- **`Feature`**: Sealed class representing a feature flag +- **`Feature.LocalFeature`**: A locally-managed feature +- **`Feature.RemoteFeature`**: A remotely-configured feature with local override +- **`FeatureState`**: Enum for remote feature states (REMOTE, LOCAL_ON, LOCAL_OFF) +- **`FeatureType`**: Enum for feature types (LOCAL, REMOTE) + +### Main Functions + +- **`createDataStore(producePath: () -> String)`**: Creates a DataStore instance +- **`FeatureHandler.addFeatures(features: List)`**: Registers features +- **`FeatureHandler.isFeatureEnabled(featureName: String)`**: Checks feature state +- **`FeatureHandler.setFeatureState(featureName: String, state: FeatureState)`**: Updates feature state +- **`FeatureHandler.getFeatures()`**: Retrieves all features with current state + +### Composables + +- **`FeatureFlipScreen(onStateChange: (String, FeatureState) -> Unit, modifier: Modifier)`**: Main UI +- **`LocalFeatures`**: CompositionLocal for providing features to the screen + +## Example: Integration with Remote Config + +```kotlin +// Fetch from your remote config service +val remoteConfig = fetchRemoteConfig() + +val features = listOf( + Feature.RemoteFeature( + name = "premium_features", + description = "Enable premium tier features", + defaultRemoteValue = remoteConfig.getBoolean("premium_features"), + state = FeatureState.REMOTE + ), + Feature.RemoteFeature( + name = "experimental_ui", + description = "New experimental UI components", + defaultRemoteValue = remoteConfig.getBoolean("experimental_ui"), + state = FeatureState.REMOTE + ) +) + +// Users can override remotely configured features in the UI if needed for testing +``` + +## Best Practices + +1. **Use descriptive names**: Make feature names self-documenting (e.g., `new_payment_flow` instead of `feature_1`) +2. **Add descriptions**: Help users understand what each feature does +3. **Start with REMOTE**: Let remote config control features initially, override only when testing +4. **Persist state**: Use `FeatureHandler` to ensure states survive app restarts +5. **Monitor overrides**: Track which features users are overriding to identify issues with remote configuration + +## Platform-Specific Notes + +### Android + +The DataStore file is created at: +``` +{appFilesDir}/feature_flip_datastore.preferences_pb +``` + +### iOS + +The DataStore file is created at: +``` +{documentDirectory}/feature_flip_datastore.preferences_pb +``` + +## Documentation + +All public APIs are documented with KDoc comments. View the documentation: +- In your IDE using Quick Documentation (Ctrl+Q / Cmd+J) +- Generate HTML docs using Dokka: `./gradlew dokkaHtml` + +## License + +See the main project LICENSE file. + +## Contributing + +Contributions are welcome! Please see the main project README for contribution guidelines. diff --git a/devview-featureflip/build.gradle.kts b/devview-featureflip/build.gradle.kts new file mode 100644 index 00000000..4eb9cb29 --- /dev/null +++ b/devview-featureflip/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + alias(libs.plugins.convention.multiplatform.library) + alias(libs.plugins.convention.compose.multiplatform) + alias(libs.plugins.convention.datastore) + alias(libs.plugins.dokka) + alias(libs.plugins.maven.publish) + alias(libs.plugins.poko) +} + +kotlin { + addDefaultDevViewTargets() + + androidLibrary { + namespace = "com.worldline.devview.featureflip" + } + + sourceSets { + commonMain { + dependencies { + api(projects.devview) + implementation(libs.kotlinx.collections.immutable) + } + } + } +} + +poko { + pokoAnnotation.set("com/worldline/devview/core/Poko") +} + +tasks.withType { + failOnNoDiscoveredTests.set(false) +} diff --git a/devview-featureflip/gradle.properties b/devview-featureflip/gradle.properties new file mode 100644 index 00000000..5d716ab2 --- /dev/null +++ b/devview-featureflip/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=devview-featureflip +POM_NAME=DevView Feature Flip \ No newline at end of file diff --git a/devview-featureflip/src/androidMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.android.kt b/devview-featureflip/src/androidMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.android.kt new file mode 100644 index 00000000..b5277d0e --- /dev/null +++ b/devview-featureflip/src/androidMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.android.kt @@ -0,0 +1,38 @@ +package com.worldline.devview.featureflip.model + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +/** + * Creates a DataStore instance for Android. + * + * The DataStore file is created in the app's files directory. + * + * @param context The Android context used to access the files directory + * @return A configured DataStore instance for storing feature preferences + */ +internal fun createDataStore(context: Context): DataStore = createDataStore( + producePath = { + context.filesDir.resolve(relative = FEATURE_FLIP_DATASTORE_NAME).absolutePath + } +) + +/** + * Android implementation of [rememberDataStore]. + * + * Creates and remembers a DataStore instance using the Android app's files directory. + * + * @return A remembered DataStore instance for feature flag persistence + */ +@Composable +internal actual fun rememberDataStore(): DataStore { + val context = LocalContext.current + + return remember { + createDataStore(context = context) + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt new file mode 100644 index 00000000..9717d961 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt @@ -0,0 +1,66 @@ +package com.worldline.devview.featureflip + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Navigation destinations for the FeatureFlip module. + * All screens within FeatureFlip are defined here. + */ +public sealed interface FeatureFlipDestination : NavKey { + /** + * Main feature flip list screen + */ + @Serializable + public data object Main : FeatureFlipDestination + + // Add more destinations as needed, for example: + // @Serializable + // public data class Detail(val featureId: String) : FeatureFlipDestination +} + +/** + * FeatureFlip module - manages feature flag toggles. + * This is a regular object, not serializable. + */ +public object FeatureFlip : Module { + override val section: Section + get() = Section.FEATURES + + override val destinations: ImmutableList = persistentListOf( + FeatureFlipDestination.Main + ) + + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit + get() = { + subclass( + subclass = FeatureFlipDestination.Main::class, + serializer = FeatureFlipDestination.Main.serializer() + ) + } + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit + ) { + entry { + Scaffold { paddingValues -> + FeatureFlipScreen( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = paddingValues) + ) + } + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt new file mode 100644 index 00000000..a91d9286 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt @@ -0,0 +1,240 @@ +package com.worldline.devview.featureflip + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Done +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateMap +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.worldline.devview.featureflip.components.featureItems +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.LocalFeatureHandler +import com.worldline.devview.featureflip.model.rememberFeatureHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Main screen for managing feature flags. + * + * Displays a searchable, filterable list of feature flags with controls to modify their state. + * Features can be filtered by type (local/remote) and state (on/off), and searched by name. + * + * @param modifier Modifier to be applied to the root container + */ +@Composable +public fun FeatureFlipScreen(modifier: Modifier = Modifier) { + val featureHandler = LocalFeatureHandler.current + val features by featureHandler.features + + @Suppress("InjectDispatcher") + val coroutineScope = rememberCoroutineScope(getContext = { Dispatchers.IO }) + + var query by remember { mutableStateOf(value = TextFieldValue()) } + + val selectedFilters = remember { + FeatureFilter + .availableEntries(features = features) + .map { it to false } + .toMutableStateMap() + } + + val filteredFeatures by remember(key1 = query, key2 = selectedFilters, key3 = features) { + derivedStateOf { + when { + query.text.isBlank() && selectedFilters.values.all { !it } -> features + + selectedFilters.values.all { !it } -> features.filter { feature -> + feature.name.contains(other = query.text, ignoreCase = true) + } + + query.text.isBlank() -> features.filter { feature -> + selectedFilters.filter { it.value }.all { (state, selected) -> + when (state) { + FeatureFilter.LOCAL -> feature is Feature.LocalFeature + FeatureFilter.REMOTE -> feature is Feature.RemoteFeature + FeatureFilter.ON -> feature.isEnabled + FeatureFilter.OFF -> !feature.isEnabled + } && selected + } + } + + else -> features.filter { feature -> + feature.name.contains( + other = query.text, + ignoreCase = true + ) && selectedFilters.filter { it.value }.all { (state, selected) -> + when (state) { + FeatureFilter.LOCAL -> feature is Feature.LocalFeature + FeatureFilter.REMOTE -> feature is Feature.RemoteFeature + FeatureFilter.ON -> feature.isEnabled + FeatureFilter.OFF -> !feature.isEnabled + } && selected + } + } + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = query, + onValueChange = { + query = it + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = "Search" + ) + }, + trailingIcon = { + IconButton( + onClick = { + query = TextFieldValue() + } + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Reset" + ) + } + } + ) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + selectedFilters + .entries + .sortedBy { it.key.ordinal } // .toSortedMap doesn't exist in KMP + .forEach { (state, selected) -> + FilterChip( + selected = selected, + label = { + Text( + text = when (state) { + FeatureFilter.LOCAL -> "Local" + FeatureFilter.REMOTE -> "Remote" + FeatureFilter.ON -> "On" + FeatureFilter.OFF -> "Off" + } + ) + }, + onClick = { + selectedFilters[state] = !selected + }, + leadingIcon = { + if (selected) { + Icon( + imageVector = Icons.Rounded.Done, + contentDescription = null + ) + } + } + ) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + featureItems( + features = filteredFeatures, + onStateChange = { featureName, state -> + coroutineScope.launch { + featureHandler.setFeatureState( + featureName = featureName, + state = state + ) + } + } + ) + } + } +} + +/** + * Internal enum representing the available filter options for features. + */ +private enum class FeatureFilter { + /** Filter for local features */ + LOCAL, + + /** Filter for remote features */ + REMOTE, + + /** Filter for enabled features */ + ON, + + /** Filter for disabled features */ + OFF; + + companion object { + /** + * Returns the available filter entries based on the feature list. + * If all features are remote, only ON/OFF filters are shown. + * Otherwise, all filter types are available. + */ + fun availableEntries(features: List): List = + if (features.filterIsInstance().size == features.size) { + listOf( + ON, + OFF + ) + } else { + entries + } + } +} + +@Preview +@Composable +private fun FeaturesScreenPreview() { + val featureHandler = rememberFeatureHandler( + features = Feature.fake() + ) + CompositionLocalProvider(value = LocalFeatureHandler provides featureHandler) { + MaterialTheme { + Scaffold { + FeatureFlipScreen() + } + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt new file mode 100644 index 00000000..e5bb7282 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt @@ -0,0 +1,183 @@ +package com.worldline.devview.featureflip.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.Feature.LocalFeature +import com.worldline.devview.featureflip.model.Feature.RemoteFeature +import com.worldline.devview.featureflip.model.FeatureState +import com.worldline.devview.featureflip.preview.FeaturePreviewParameterProvider + +/** + * Adds feature items to a LazyList. + * + * Creates a list of feature cards with appropriate styling and dividers. + * Each feature is rendered with animation support and proper item keys for efficient recomposition. + * + * @param features List of features to display + * @param onStateChange Callback invoked when a feature's state changes. + * Parameters are the feature name and the new state. + * @param modifier Modifier to apply to each feature item + */ +internal fun LazyListScope.featureItems( + features: List, + onStateChange: (String, FeatureState) -> Unit, + modifier: Modifier = Modifier +) { + features.forEachIndexed { index, feature -> + item( + key = feature.name, + contentType = feature::class.simpleName + ) { + FeatureItem( + modifier = modifier + .animateItem(), + feature = feature, + totalFeatures = features.size, + index = index, + isLastIndex = index == features.lastIndex, + onStateChange = { state -> + onStateChange(feature.name, state) + } + ) + } + } + item { + Spacer( + modifier = Modifier.padding(all = 16.dp) + ) + } +} + +/** + * Displays a single feature item card. + * + * Renders a feature with its name, description, and appropriate control: + * - [Feature.LocalFeature] shows a simple on/off Switch + * - [Feature.RemoteFeature] shows a tri-state switch (Remote/Off/On) + * + * The card shape adapts based on its position in a list to create a grouped appearance. + * + * @param feature The feature to display + * @param totalFeatures Total number of features in the list (for shape calculations) + * @param index The index of this feature in the list + * @param isLastIndex Whether this is the last item in the list + * @param onStateChange Callback invoked when the feature's state changes + * @param modifier Modifier to apply to the card + */ +@Composable +private fun FeatureItem( + feature: Feature, + totalFeatures: Int, + index: Int, + isLastIndex: Boolean, + onStateChange: (FeatureState) -> Unit, + modifier: Modifier = Modifier +) { + val baseShape = MaterialTheme.shapes.medium + + val shape = when (totalFeatures) { + 1 -> baseShape + else -> when (index) { + 0 -> baseShape.copy( + bottomStart = ZeroCornerSize, + bottomEnd = ZeroCornerSize + ) + + totalFeatures - 1 -> baseShape.copy( + topStart = ZeroCornerSize, + topEnd = ZeroCornerSize + ) + + else -> RoundedCornerShape(percent = 0) + } + } + + Card( + modifier = modifier + .fillMaxWidth(), + shape = shape + ) { + Row( + modifier = Modifier + .padding(all = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(space = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(weight = 1f), + verticalArrangement = Arrangement.spacedBy(space = 4.dp) + ) { + Text( + text = feature.name, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold + ) + ) + feature.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall + ) + } + } + + when (feature) { + is LocalFeature -> Switch( + checked = feature.isEnabled, + onCheckedChange = { + onStateChange(if (it) FeatureState.LOCAL_ON else FeatureState.LOCAL_OFF) + } + ) + + is RemoteFeature -> FeatureTriStateSwitch( + feature = feature, + onStateChange = onStateChange + ) + } + } + if (!isLastIndex) { + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + } + } +} + +@Preview +@Composable +private fun FeatureItemPreview( + @PreviewParameter(provider = FeaturePreviewParameterProvider::class) feature: Feature +) { + MaterialTheme { + Surface { + LazyColumn { + featureItems( + features = listOf(element = feature), + onStateChange = { _, _ -> } + ) + } + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureTriStateSwitch.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureTriStateSwitch.kt new file mode 100644 index 00000000..eea57f1b --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureTriStateSwitch.kt @@ -0,0 +1,463 @@ +@file:Suppress("TooManyFunctions", "CommentOverPrivateFunction") + +package com.worldline.devview.featureflip.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.CheckCircleOutline +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalTonalElevationEnabled +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButtonColors +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope +import androidx.compose.material3.Surface +import androidx.compose.material3.ripple +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.MultiContentMeasurePolicy +import androidx.compose.ui.layout.layout +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMaxBy +import com.worldline.devview.featureflip.model.Feature.RemoteFeature +import com.worldline.devview.featureflip.model.FeatureState +import com.worldline.devview.featureflip.preview.RemoteFeaturePreviewParameterProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * A tri-state segmented button switch for controlling remote feature flags. + * + * Displays three options: + * - Remote (cloud icon): Use the default remote configuration value + * - Off (cancel icon): Force the feature off locally + * - On (check icon): Force the feature on locally + * + * The active button's color changes based on the feature's effective state. + * + * @param feature The remote feature to control + * @param onStateChange Callback invoked when the state changes + * @param modifier Modifier to apply to the switch + * @param featureOnColor Color used when a feature is effectively enabled + * @param featureOffColor Color used when a feature is effectively disabled + */ +@Composable +internal fun FeatureTriStateSwitch( + feature: RemoteFeature, + onStateChange: (FeatureState) -> Unit, + modifier: Modifier = Modifier, + featureOnColor: Color = MaterialTheme.colorScheme.primary, + featureOffColor: Color = MaterialTheme.colorScheme.error +) { + val selectedIndex = feature.state.ordinal + FeatureSingleChoiceSegmentedButtonRow( + modifier = modifier + ) { + FeatureState.entries.forEachIndexed { index, featureState -> + val selected = index == selectedIndex + FeatureSegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = FeatureState.entries.size, + baseShape = MaterialTheme.shapes.small + ), + onClick = { + onStateChange(featureState) + }, + selected = selected, + icon = { + Icon( + imageVector = when (featureState) { + FeatureState.REMOTE -> Icons.Outlined.Cloud + FeatureState.LOCAL_ON -> Icons.Outlined.CheckCircleOutline + FeatureState.LOCAL_OFF -> Icons.Outlined.Cancel + }, + contentDescription = featureState.name + ) + }, + colors = SegmentedButtonDefaults.colors( + activeContainerColor = when (featureState) { + FeatureState.REMOTE -> when (feature.defaultRemoteValue) { + true -> featureOnColor + false -> featureOffColor + } + + FeatureState.LOCAL_OFF -> featureOffColor + FeatureState.LOCAL_ON -> featureOnColor + } + ) + ) + } + } +} + +/** + * A custom single-choice segmented button row for feature state selection. + * + * @param modifier Modifier to apply to the row + * @param space Spacing between buttons (defaults to border width for overlap effect) + * @param content The button content to display + */ +@Composable +private fun FeatureSingleChoiceSegmentedButtonRow( + modifier: Modifier = Modifier, + space: Dp = SegmentedButtonDefaults.BorderWidth, + content: @Composable SingleChoiceSegmentedButtonRowScope.() -> Unit +) { + Row( + modifier = modifier + .defaultMinSize( + minHeight = LocalMinimumInteractiveComponentSize.current + ).selectableGroup() + .width(intrinsicSize = IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(space = -space), + verticalAlignment = Alignment.CenterVertically + ) { + val scope = remember { + SingleChoiceSegmentedButtonScopeWrapper( + scope = this + ) + } + scope.content() + } +} + +/** + * A single button within the feature segmented button row. + * + * @param selected Whether this button is currently selected + * @param onClick Callback when the button is clicked + * @param shape The shape to apply to this button (varies by position) + * @param icon The icon content to display + * @param modifier Modifier to apply to the button + * @param enabled Whether the button is enabled for interaction + * @param colors Color scheme for the button states + * @param border Border stroke for the button + * @param interactionSource Optional interaction source for tracking user interactions + * @param padding Padding applied to the button content + */ +@Composable +private fun SingleChoiceSegmentedButtonRowScope.FeatureSegmentedButton( + selected: Boolean, + onClick: () -> Unit, + shape: Shape, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(), + border: BorderStroke = + SegmentedButtonDefaults.borderStroke( + color = colors.borderColor( + enabled = enabled, + active = selected + ) + ), + interactionSource: MutableInteractionSource? = null, + padding: PaddingValues = PaddingValues(all = 8.dp) +) { + val mutableInteractionSource = interactionSource ?: remember { MutableInteractionSource() } + val containerColor = colors.containerColor(enabled = enabled, active = selected) + val contentColor = colors.contentColor(enabled = enabled, checked = selected) + val interactionCount = mutableInteractionSource.interactionCountAsState() + + val absoluteElevation = LocalAbsoluteTonalElevation.current + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalAbsoluteTonalElevation provides absoluteElevation + ) { + Box( + modifier = modifier + .weight(weight = 1f) + .interactionZIndex(checked = selected, interactionCount = interactionCount) + .semantics { role = Role.RadioButton } + .surface( + shape = shape, + backgroundColor = + surfaceColorAtElevation( + color = containerColor, + elevation = absoluteElevation + ), + border = border, + shadowElevation = 0f + ).selectable( + selected = selected, + interactionSource = mutableInteractionSource, + indication = ripple(), + enabled = enabled, + onClick = onClick + ), + propagateMinConstraints = true + ) { + FeatureSegmentedButtonContent(icon = icon, padding = padding) + } + } +} + +/** + * Content layout for a feature segmented button. + * + * @param icon The icon to display in the button + * @param padding Padding to apply around the content + */ +@Composable +private fun FeatureSegmentedButtonContent(icon: @Composable () -> Unit, padding: PaddingValues) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(paddingValues = padding) + ) { + val scope = rememberCoroutineScope() + val measurePolicy = remember { + SegmentedButtonContentMeasurePolicy(scope = scope) + } + + Layout( + modifier = Modifier.height(intrinsicSize = IntrinsicSize.Min), + contents = listOf(element = icon), + measurePolicy = measurePolicy + ) + } +} + +// region SegmentedButton overrides + +/** + * Gets the appropriate border color based on enabled and active state. + */ +private fun SegmentedButtonColors.borderColor(enabled: Boolean, active: Boolean): Color = when { + enabled && active -> activeBorderColor + enabled && !active -> inactiveBorderColor + !enabled && active -> disabledActiveBorderColor + else -> disabledInactiveBorderColor +} + +/** + * Gets the appropriate container color based on enabled and active state. + */ +private fun SegmentedButtonColors.containerColor(enabled: Boolean, active: Boolean): Color = when { + enabled && active -> activeContainerColor + enabled && !active -> inactiveContainerColor + !enabled && active -> disabledActiveContainerColor + else -> disabledInactiveContainerColor +} + +/** + * Gets the appropriate content color based on enabled and checked state. + */ +private fun SegmentedButtonColors.contentColor(enabled: Boolean, checked: Boolean): Color = when { + enabled && checked -> activeContentColor + enabled && !checked -> inactiveContentColor + !enabled && checked -> disabledActiveContentColor + else -> disabledInactiveContentColor +} + +/** + * Custom measure policy for animating segmented button content transitions. + * + * @property scope Coroutine scope for launching animations + */ +internal class SegmentedButtonContentMeasurePolicy(private val scope: CoroutineScope) : + MultiContentMeasurePolicy { + private var animatable: Animatable? = null + private var initialOffset: Int? = null + + override fun MeasureScope.measure( + measurables: List>, + constraints: Constraints + ): MeasureResult { + val (contentMeasurables) = measurables + val contentPlaceables = contentMeasurables.fastMap { it.measure(constraints = constraints) } + val width = contentPlaceables.fastMaxBy { it.width }?.width ?: 0 + val height = contentPlaceables.fastMaxBy { it.height }?.height ?: 0 + val offsetX = 0 + + if (initialOffset == null) { + initialOffset = offsetX + } else { + val anim = + animatable + ?: Animatable( + initialValue = initialOffset ?: offsetX, + typeConverter = Int.VectorConverter + ).also { animatable = it } + if (anim.targetValue != offsetX) { + scope.launch { + anim.animateTo( + targetValue = offsetX, + animationSpec = tween(durationMillis = 350) + ) + } + } + } + + return layout(width = width, height = height) { + contentPlaceables.fastForEach { it.place(x = offsetX, y = (height - it.height) / 2) } + } + } +} + +/** + * Tracks the number of active interactions (press, focus) as a State. + */ +@Composable +private fun InteractionSource.interactionCountAsState(): State { + val interactionCount = remember { mutableIntStateOf(value = 0) } + LaunchedEffect(key1 = this) { + this@interactionCountAsState.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press, + is FocusInteraction.Focus -> { + interactionCount.intValue++ + } + + is PressInteraction.Release, + is FocusInteraction.Unfocus, + is PressInteraction.Cancel -> { + interactionCount.intValue-- + } + } + } + } + + return interactionCount +} + +/** + * Applies z-index based on checked state and interaction count. + * Ensures the selected button and interacted buttons appear above others. + */ +private fun Modifier.interactionZIndex(checked: Boolean, interactionCount: State) = + this.layout { measurable, constraints -> + val placeable = measurable.measure(constraints = constraints) + layout(width = placeable.width, height = placeable.height) { + val zIndex = interactionCount.value + if (checked) CHECKED_Z_INDEX_FACTOR else 0f + placeable.place(x = 0, y = 0, zIndex = zIndex) + } + } + +private const val CHECKED_Z_INDEX_FACTOR = 5f + +private class SingleChoiceSegmentedButtonScopeWrapper(scope: RowScope) : + SingleChoiceSegmentedButtonRowScope, + RowScope by scope + +// endregion + +// region Surface overrides + +/** + * Applies surface styling including shape, background color, border, and shadow elevation. + */ +@Stable +private fun Modifier.surface( + shape: Shape, + backgroundColor: Color, + border: BorderStroke?, + shadowElevation: Float +) = this + .then( + other = if (shadowElevation > 0f) { + Modifier.graphicsLayer( + shadowElevation = shadowElevation, + shape = shape, + clip = false + ) + } else { + Modifier + } + ).then( + other = if (border != null) Modifier.border(border = border, shape = shape) else Modifier + ).background(color = backgroundColor, shape = shape) + .clip(shape = shape) + +/** + * Calculates the surface color at a given elevation, applying tonal elevation if appropriate. + */ +@Composable +private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color = + MaterialTheme.colorScheme.applyTonalElevation(backgroundColor = color, elevation = elevation) + +/** + * Applies tonal elevation to a background color if enabled and the color is a surface color. + */ +@Composable +@ReadOnlyComposable +internal fun ColorScheme.applyTonalElevation(backgroundColor: Color, elevation: Dp): Color { + val tonalElevationEnabled = LocalTonalElevationEnabled.current + return if (backgroundColor == surface && tonalElevationEnabled) { + surfaceColorAtElevation(elevation = elevation) + } else { + backgroundColor + } +} + +// endregion + +@Preview +@Composable +private fun FeatureTriStateSwitchPreview( + @PreviewParameter(RemoteFeaturePreviewParameterProvider::class) feature: RemoteFeature +) { + MaterialTheme { + Surface { + FeatureTriStateSwitch( + feature = feature, + onStateChange = { } + ) + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/Feature.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/Feature.kt new file mode 100644 index 00000000..e55fbe23 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/Feature.kt @@ -0,0 +1,113 @@ +package com.worldline.devview.featureflip.model + +import kotlin.random.Random + +/** + * Sealed class representing a feature flag in the application. + * + * Features can be either [RemoteFeature] (controlled by a remote configuration service) + * or [LocalFeature] (stored and managed locally on the device). + */ +public sealed class Feature { + /** + * The unique identifier name of the feature. + */ + public abstract val name: String + + /** + * Optional description explaining what this feature does. + */ + public abstract val description: String? + + /** + * Whether the feature is currently enabled. + * The actual value depends on the feature type and its current state. + */ + public abstract val isEnabled: Boolean + + internal companion object { + internal fun fake(amount: Int = 5) = List(size = amount) { + when (Random.nextBoolean()) { + true -> RemoteFeature.fake(index = it) + false -> LocalFeature.fake(index = it) + } + } + } + + /** + * A feature flag controlled by a remote configuration service. + * + * Remote features support three states: + * - [FeatureState.REMOTE]: Use the default remote value + * - [FeatureState.LOCAL_ON]: Override to force the feature on + * - [FeatureState.LOCAL_OFF]: Override to force the feature off + * + * @property name The unique identifier name of the feature + * @property description Optional description of the feature's functionality + * @property defaultRemoteValue The default value provided by the remote configuration + * @property state The current state of the feature (remote or local override) + */ + public data class RemoteFeature( + override val name: String, + override val description: String?, + val defaultRemoteValue: Boolean, + val state: FeatureState + ) : Feature() { + override val isEnabled: Boolean + get() = when (state) { + FeatureState.REMOTE -> defaultRemoteValue + FeatureState.LOCAL_OFF -> false + FeatureState.LOCAL_ON -> true + } + + internal companion object { + fun fakeList(amount: Int = 5) = List(size = amount) { + RemoteFeature( + name = "Feature ${it + 1}", + description = "Description for feature ${it + 1}", + defaultRemoteValue = Random.nextBoolean(), + state = FeatureState.entries.random() + ) + } + + fun fake(index: Int) = RemoteFeature( + name = "Feature ${index + 5}", + description = "Description for feature ${index + 5}", + defaultRemoteValue = Random.nextBoolean(), + state = FeatureState.REMOTE + ) + } + } + + /** + * A locally-managed feature flag stored on the device. + * + * Local features have a simple on/off state without remote configuration. + * They are useful for features that should be controlled per-device. + * + * @property name The unique identifier name of the feature + * @property description Optional description of the feature's functionality + * @property isEnabled Whether the feature is currently enabled + */ + public data class LocalFeature( + override val name: String, + override val description: String?, + override val isEnabled: Boolean + ) : Feature() { + internal companion object { + fun fakeList(amount: Int = 5) = List(size = amount) { + LocalFeature( + name = "Feature ${it + 1}", + description = "Description for feature ${it + 1}", + isEnabled = Random.nextBoolean() + ) + } + + fun fake(index: Int) = LocalFeature( + name = "Feature ${index + 1}", + description = "Description for feature ${index + 1}", + isEnabled = Random.nextBoolean() + ) + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureHandler.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureHandler.kt new file mode 100644 index 00000000..ec45866c --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureHandler.kt @@ -0,0 +1,299 @@ +@file:Suppress("CommentOverPrivateFunction", "CommentOverPrivateProperty") + +package com.worldline.devview.featureflip.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import okio.IOException + +/** + * Internal handler for managing feature flag state persistence. + * + * This class handles storing and retrieving feature flag states using DataStore. + * It maintains a registry of features and their associated preference keys. + * + * @property dataStore The DataStore instance used for persistence + * @param initialFeatures The initial list of features to register and manage + */ +public class FeatureHandler( + private val dataStore: DataStore, + initialFeatures: List +) { + /** + * Internal registry mapping features to their corresponding DataStore preference keys. + * + * This mutable list maintains pairs of [Feature] instances and their associated + * [Preferences.Key]. Local features use Boolean keys, while remote features use Int keys + * to store their state ordinal values. + */ + private val featuresAndPreferenceKeys: MutableList>> = + initialFeatures + .map { feature -> + val preferenceKey = when (feature) { + is Feature.LocalFeature -> booleanPreferencesKey(name = feature.name) + is Feature.RemoteFeature -> intPreferencesKey(name = feature.name) + } + feature to preferenceKey + }.toMutableList() + + /** + * Checks whether a feature is currently enabled. + * + * For [Feature.LocalFeature], returns the stored boolean value. + * For [Feature.RemoteFeature], resolves the enabled state based on the current [FeatureState]: + * - [FeatureState.REMOTE]: Returns the default remote value + * - [FeatureState.LOCAL_ON]: Returns true + * - [FeatureState.LOCAL_OFF]: Returns false + * + * @param featureName The name of the feature to check + * @return A Flow emitting the current enabled state of the feature + * @throws IllegalArgumentException if no feature with the given name is registered + */ + public fun isFeatureEnabledFlow(featureName: String): Flow = dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(value = emptyPreferences()) + } else { + throw exception + } + }.map { preferences -> + val featureAndPreferenceKey = + featuresAndPreferenceKeys.firstOrNull { it.first.name == featureName } + ?: throw IllegalArgumentException("Feature with name $featureName not found") + + when (val feature = featureAndPreferenceKey.first) { + is Feature.LocalFeature -> { + @Suppress("UNCHECKED_CAST") + val preferenceKey = + featureAndPreferenceKey.second as Preferences.Key + preferences[preferenceKey] ?: false + } + + is Feature.RemoteFeature -> { + @Suppress("UNCHECKED_CAST") + val preferenceKey = featureAndPreferenceKey.second as Preferences.Key + val state = FeatureState.fromOrdinal( + ordinal = preferences[preferenceKey] ?: FeatureState.REMOTE.ordinal + ) + when (state) { + FeatureState.REMOTE -> feature.defaultRemoteValue + FeatureState.LOCAL_OFF -> false + FeatureState.LOCAL_ON -> true + } + } + } + } + + /** + * Checks whether a feature is currently enabled as a Compose [State]. + * + * This composable function returns a State that automatically updates when the + * feature's enabled status changes. It uses [isFeatureEnabledFlow] internally + * and collects it as lifecycle-aware state. + * + * The initial value is determined from the registered feature's default state, + * or false if the feature is not found. + * + * @param featureName The name of the feature to check + * @return A [State] object emitting the current enabled state of the feature + */ + @Composable + public fun isFeatureEnabled(featureName: String): State = + isFeatureEnabledFlow(featureName = featureName) + .collectAsStateWithLifecycle( + initialValue = featuresAndPreferenceKeys + .map { it.first } + .firstOrNull { feature -> + feature.name == featureName + }?.isEnabled ?: false + ) + + /** + * Updates the state of a feature flag. + * + * For [Feature.LocalFeature], sets the enabled boolean: + * - [FeatureState.LOCAL_ON] sets to true + * - [FeatureState.LOCAL_OFF] sets to false + * - [FeatureState.REMOTE] throws an exception (not applicable to local features) + * + * For [Feature.RemoteFeature], sets the state ordinal to control the feature. + * + * @param featureName The name of the feature to update + * @param state The new state to set + * @throws IllegalArgumentException if no feature with the given name is registered, + * or if trying to set REMOTE state for a local feature + */ + internal suspend fun setFeatureState(featureName: String, state: FeatureState) { + dataStore.edit { preferences -> + val featureAndPreferenceKey = + featuresAndPreferenceKeys.firstOrNull { it.first.name == featureName } + ?: throw IllegalArgumentException("Feature with name $featureName not found") + + when (featureAndPreferenceKey.first) { + is Feature.LocalFeature -> { + @Suppress("UNCHECKED_CAST") + val preferenceKey = featureAndPreferenceKey.second as Preferences.Key + when (state) { + FeatureState.REMOTE -> throw IllegalArgumentException( + "Cannot set remote state for local feature" + ) + + FeatureState.LOCAL_OFF -> preferences[preferenceKey] = false + FeatureState.LOCAL_ON -> preferences[preferenceKey] = true + } + } + + is Feature.RemoteFeature -> { + @Suppress("UNCHECKED_CAST") + val preferenceKey = featureAndPreferenceKey.second as Preferences.Key + preferences[preferenceKey] = state.ordinal + } + } + } + } + + /** + * Registers and persists a list of features. + * + * This method adds the features to the internal registry (if not already present) + * and stores their initial state in DataStore. Features with the same name will + * not be added twice. + * + * @param featuresToAdd The list of features to register and persist + */ + public suspend fun addFeatures(featuresToAdd: List) { + val newFeaturesAndPreferenceKeys = featuresToAdd.zip( + other = featuresToAdd.map { + when (it) { + is Feature.LocalFeature -> booleanPreferencesKey(name = it.name) + is Feature.RemoteFeature -> intPreferencesKey(name = it.name) + } + } + ) + + newFeaturesAndPreferenceKeys.forEach { (feature, preferenceKey) -> + if (!featuresAndPreferenceKeys.any { feature.name == it.first.name }) { + featuresAndPreferenceKeys.add(element = feature to preferenceKey) + } + + dataStore.edit { preferences -> + when (feature) { + is Feature.LocalFeature -> { + @Suppress("UNCHECKED_CAST") + val castPreferenceKey = preferenceKey as Preferences.Key + preferences[castPreferenceKey] = feature.isEnabled + } + + is Feature.RemoteFeature -> { + @Suppress("UNCHECKED_CAST") + val castPreferenceKey = preferenceKey as Preferences.Key + preferences[castPreferenceKey] = feature.state.ordinal + } + } + } + } + } + + /** + * Retrieves all registered features with their current persisted state. + * + * The returned features will have their state/enabled properties updated + * based on what's stored in DataStore. + * + * @return A Flow emitting the list of all registered features with current state + */ + private fun getFeatures(): Flow> = dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(value = emptyPreferences()) + } else { + throw exception + } + }.map { preferences -> + featuresAndPreferenceKeys.map { (feature, preferenceKey) -> + when (feature) { + is Feature.LocalFeature -> { + @Suppress("UNCHECKED_CAST") + val castPreferenceKey = preferenceKey as Preferences.Key + feature.copy( + isEnabled = preferences[castPreferenceKey] ?: feature.isEnabled + ) + } + + is Feature.RemoteFeature -> { + @Suppress("UNCHECKED_CAST") + val castPreferenceKey = preferenceKey as Preferences.Key + val state = FeatureState.fromOrdinal( + ordinal = preferences[castPreferenceKey] ?: feature.state.ordinal + ) + feature.copy(state = state) + } + } + } + } + + /** + * Provides access to all registered features as a Compose [State]. + * + * This property returns a lifecycle-aware State containing the current list of all + * registered features with their persisted state values. The State automatically + * updates when any feature's state changes in DataStore. + * + * This is an internal property used primarily for feature management UI components. + * + * @return A [State] containing the list of all features with their current state + */ + internal val features: State> + @Composable get() = getFeatures() + .collectAsStateWithLifecycle( + initialValue = featuresAndPreferenceKeys.map { it.first } + ) +} + +/** + * Remembers and returns a [FeatureHandler] instance for the current composition. + * + * This composable creates a FeatureHandler backed by a platform-specific DataStore. + * The instance is remembered across recompositions. + * + * @param features The list of features to initialize the handler with + * @return A remembered [FeatureHandler] instance + */ +@Composable +public fun rememberFeatureHandler(features: List): FeatureHandler { + val dataStore = rememberDataStore() + + return remember(key1 = dataStore) { + FeatureHandler( + dataStore = dataStore, + initialFeatures = features + ) + } +} + +/** + * CompositionLocal providing access to the current [FeatureHandler]. + * + * Components can use this to obtain the FeatureHandler instance + * for managing feature flags within the composition. + * + * Must be provided by the parent composable before use. + * Throws an error if accessed without being initialized. + */ +public val LocalFeatureHandler: ProvidableCompositionLocal = + staticCompositionLocalOf { + error(message = "No FeatureHandler provided") + } diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureState.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureState.kt new file mode 100644 index 00000000..7d3a22d0 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureState.kt @@ -0,0 +1,44 @@ +package com.worldline.devview.featureflip.model + +/** + * Represents the state of a feature flag. + * + * This enum is used to manage the current state of remote features, allowing + * local overrides of remotely-configured feature flags. + * + * **Note:** When persisting the state, use the [ordinal] property to store it + * as an integer, and [fromOrdinal] to restore it. + */ +public enum class FeatureState { + /** + * Use the default value from remote configuration. + * The feature's enabled state will match the remote server's setting. + */ + REMOTE, + + /** + * Local override to force the feature off, regardless of remote configuration. + */ + LOCAL_OFF, + + /** + * Local override to force the feature on, regardless of remote configuration. + */ + LOCAL_ON; + + public companion object { + /** + * Converts an ordinal value back to a [FeatureState]. + * + * @param ordinal The ordinal value to convert (0 = REMOTE, 1 = LOCAL_OFF, 2 = LOCAL_ON) + * @return The corresponding [FeatureState] + * @throws IllegalArgumentException if the ordinal is not valid + */ + public fun fromOrdinal(ordinal: Int): FeatureState = when (ordinal) { + 0 -> REMOTE + 1 -> LOCAL_OFF + 2 -> LOCAL_ON + else -> throw IllegalArgumentException("Unknown ordinal: $ordinal") + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureType.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureType.kt new file mode 100644 index 00000000..0e95c1ee --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/FeatureType.kt @@ -0,0 +1,38 @@ +package com.worldline.devview.featureflip.model + +/** + * Represents the type of a feature flag. + * + * Feature types determine whether a feature is controlled remotely or locally. + * + * **Note:** When persisting the type, use the [ordinal] property to store it + * as an integer, and [fromOrdinal] to restore it. + */ +public enum class FeatureType { + /** + * A remotely-controlled feature flag. + * These features are configured via a remote service and can be locally overridden. + */ + REMOTE, + + /** + * A locally-controlled feature flag. + * These features are managed entirely on the device. + */ + LOCAL; + + public companion object { + /** + * Converts an ordinal value back to a [FeatureType]. + * + * @param ordinal The ordinal value to convert (0 = REMOTE, 1 = LOCAL) + * @return The corresponding [FeatureType] + * @throws IllegalArgumentException if the ordinal is not valid + */ + public fun fromOrdinal(ordinal: Int): FeatureType = when (ordinal) { + 0 -> REMOTE + 1 -> LOCAL + else -> throw IllegalArgumentException("Unknown ordinal: $ordinal") + } + } +} diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.kt new file mode 100644 index 00000000..bd782e66 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.kt @@ -0,0 +1,40 @@ +package com.worldline.devview.featureflip.model + +import androidx.compose.runtime.Composable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import okio.Path.Companion.toPath + +/** + * Creates a DataStore instance for feature flag persistence. + * + * This function creates a preferences DataStore at the path provided by the lambda. + * It's used by platform-specific implementations to create the DataStore with + * the appropriate file path for each platform. + * + * @param producePath A lambda that returns the absolute path where the DataStore file should be created + * @return A configured DataStore instance for storing feature preferences + */ +public fun createDataStore(producePath: () -> String): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { + producePath().toPath() + } + ) + +/** + * The filename used for the feature flip DataStore preferences file. + */ +internal const val FEATURE_FLIP_DATASTORE_NAME: String = "feature_flip_datastore.preferences_pb" + +/** + * Platform-specific composable that remembers and returns a DataStore instance. + * + * Each platform (Android, iOS) implements this to create a DataStore at the + * appropriate location for that platform. + * + * @return A remembered DataStore instance for feature flag persistence + */ +@Composable +internal expect fun rememberDataStore(): DataStore diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/preview/FeaturePreviewParameterProvider.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/preview/FeaturePreviewParameterProvider.kt new file mode 100644 index 00000000..8906dea0 --- /dev/null +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/preview/FeaturePreviewParameterProvider.kt @@ -0,0 +1,110 @@ +package com.worldline.devview.featureflip.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.Feature.LocalFeature +import com.worldline.devview.featureflip.model.Feature.RemoteFeature +import com.worldline.devview.featureflip.model.FeatureState + +/** + * Preview parameter provider for [Feature] instances. + * + * Provides various states of both [Feature.LocalFeature] and [Feature.RemoteFeature] + * for Compose preview purposes. + */ +internal class FeaturePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RemoteFeature( + name = "Remote Feature Enabled", + description = "Remote feature with default value enabled", + defaultRemoteValue = true, + state = FeatureState.REMOTE + ), + RemoteFeature( + name = "Remote Feature Disabled", + description = "Remote feature with default value disabled", + defaultRemoteValue = false, + state = FeatureState.REMOTE + ), + RemoteFeature( + name = "Remote Feature Local Off", + description = "Remote feature overridden locally to OFF", + defaultRemoteValue = false, + state = FeatureState.LOCAL_OFF + ), + RemoteFeature( + name = "Remote Feature Local On", + description = "Remote feature overridden locally to ON", + defaultRemoteValue = true, + state = FeatureState.LOCAL_ON + ), + LocalFeature( + name = "Local Feature Enabled", + description = "Local feature that is enabled", + isEnabled = true + ), + LocalFeature( + name = "Local Feature Disabled", + description = "Local feature that is disabled", + isEnabled = false + ) + ) +} + +/** + * Preview parameter provider specifically for [Feature.RemoteFeature] instances. + * + * Provides remote features in all possible states (REMOTE, LOCAL_OFF, LOCAL_ON) + * with both enabled and disabled default remote values. + */ +internal class RemoteFeaturePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RemoteFeature( + name = "Remote Feature Enabled", + description = "Remote feature with default value enabled", + defaultRemoteValue = true, + state = FeatureState.REMOTE + ), + RemoteFeature( + name = "Remote Feature Disabled", + description = "Remote feature with default value disabled", + defaultRemoteValue = false, + state = FeatureState.REMOTE + ), + RemoteFeature( + name = "Remote Feature Local Off", + description = "Remote feature overridden locally to OFF", + defaultRemoteValue = false, + state = FeatureState.LOCAL_OFF + ), + RemoteFeature( + name = "Remote Feature Local On", + description = "Remote feature overridden locally to ON", + defaultRemoteValue = true, + state = FeatureState.LOCAL_ON + ) + ) +} + +/** + * Preview parameter provider specifically for [Feature.LocalFeature] instances. + * + * Provides local features in both enabled and disabled states for previews. + */ +internal class LocalFeaturePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + LocalFeature( + name = "Local Feature Enabled", + description = "Local feature that is enabled", + isEnabled = true + ), + LocalFeature( + name = "Local Feature Disabled", + description = "Local feature that is disabled", + isEnabled = false + ) + ) +} diff --git a/devview-featureflip/src/iosMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.ios.kt b/devview-featureflip/src/iosMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.ios.kt new file mode 100644 index 00000000..d521fdff --- /dev/null +++ b/devview-featureflip/src/iosMain/kotlin/com/worldline/devview/featureflip/model/createDataStore.ios.kt @@ -0,0 +1,44 @@ +package com.worldline.devview.featureflip.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +/** + * Creates a DataStore instance for iOS. + * + * The DataStore file is created in the iOS app's document directory. + * + * @return A configured DataStore instance for storing feature preferences + */ +@OptIn(ExperimentalForeignApi::class) +internal fun createDataStore(): DataStore = createDataStore( + producePath = { + val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null + ) + requireNotNull(documentDirectory).path + "/$FEATURE_FLIP_DATASTORE_NAME" + } +) + +/** + * iOS implementation of [rememberDataStore]. + * + * Creates and remembers a DataStore instance using the iOS app's document directory. + * + * @return A remembered DataStore instance for feature flag persistence + */ +@Composable +internal actual fun rememberDataStore(): DataStore = remember { + createDataStore() +} diff --git a/devview/build.gradle.kts b/devview/build.gradle.kts index ca7fcbbc..9714b268 100644 --- a/devview/build.gradle.kts +++ b/devview/build.gradle.kts @@ -23,7 +23,7 @@ kotlin { } poko { - pokoAnnotation.set("com/worldline/devview/Poko") + pokoAnnotation.set("com/worldline/devview/core/Poko") } tasks.withType { diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt b/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt index 87b9cd95..c44d98aa 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt @@ -1,5 +1,6 @@ package com.worldline.devview +import androidx.compose.animation.AnimatedVisibility import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -7,14 +8,26 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationEventHandler +import androidx.navigationevent.compose.rememberNavigationEventState import androidx.savedstate.serialization.SavedStateConfiguration +import com.worldline.devview.core.Module import kotlinx.collections.immutable.ImmutableList import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic +/** + * Main entry point for DevView. + * + * @param openDevView Callback that returns true when DevView should be shown + * @param closeDevView Callback to close DevView + * @param modules List of modules to display (use [com.worldline.devview.core.rememberModules] to build) + * @param modifier Optional modifier + */ @Composable public fun DevView( - openDevView: () -> Boolean, + devViewIsOpen: Boolean, closeDevView: () -> Unit, modules: ImmutableList, modifier: Modifier = Modifier @@ -24,8 +37,9 @@ public fun DevView( serializersModule = SerializersModule { polymorphic(baseClass = NavKey::class) { subclass(subclass = Home::class, serializer = Home.serializer()) + // Register all module destination serializers modules.forEach { module -> - module.asSubclass + module.registerSerializers(this) } } } @@ -33,24 +47,54 @@ public fun DevView( Home ) - if (openDevView()) { + val navigationState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) + + NavigationEventHandler( + state = navigationState, + onBackCompleted = { + if (backstack.size == 1 && backstack.first() == Home) { + // Close DevView if we're at the root + closeDevView() + } + } + ) + + AnimatedVisibility( + visible = devViewIsOpen + ) { Scaffold( modifier = modifier ) { NavDisplay( backStack = backstack, - onBack = { - backstack.removeLastOrNull() - }, entryProvider = entryProvider { + // Home screen entry entry { HomeScreen( modules = modules, openModule = { module -> - backstack.add(element = module) + // Navigate to the module's first destination + val firstDestination = module.destinations.firstOrNull() + if (firstDestination != null) { + backstack.add(element = firstDestination) + } } ) } + + // Register each module's content + modules.forEach { module -> + with(receiver = module) { + this@entryProvider.registerContent( + onNavigateBack = { + backstack.removeLastOrNull() + }, + onNavigate = { destination -> + backstack.add(element = destination) + } + ) + } + } } ) } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt b/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt index af26b4b3..6396ece2 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt @@ -14,6 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import com.worldline.devview.core.previewModule import com.worldline.devview.internal.components.ModuleItem import com.worldline.devview.internal.components.ModulePosition import kotlinx.serialization.Serializable @@ -91,11 +94,11 @@ public data object Home : NavKey private fun HomeScreenPreview() { HomeScreen( modules = listOf( - Module.AppInfo, - Module.FeatureFlip, - Module.Console, - Module.Analytics, - Module.AppSpecific + previewModule(section = Section.SETTINGS, name = "AppInfo"), + previewModule(section = Section.FEATURES, name = "FeatureFlip"), + previewModule(section = Section.LOGGING, name = "Console"), + previewModule(section = Section.LOGGING, name = "Analytics"), + previewModule(section = Section.CUSTOM, name = "AppSpecific") ), openModule = {} ) diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/Module.kt b/devview/src/commonMain/kotlin/com/worldline/devview/Module.kt deleted file mode 100644 index 2b42c15c..00000000 --- a/devview/src/commonMain/kotlin/com/worldline/devview/Module.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.worldline.devview - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AppSettingsAlt -import androidx.compose.material.icons.rounded.DeveloperMode -import androidx.compose.material.icons.rounded.FormatListNumbered -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable -import kotlinx.serialization.modules.PolymorphicModuleBuilder - -public sealed interface Module : NavKey { - public val section: Section - - public val icon: ImageVector - get() = section.icon - public val containerColor: Color - get() = Color(color = 0xFF326EE6) - public val contentColor: Color - get() = Color(color = 0xFFE6E6E6) - - public val subtitle: String? - get() = null - - public val asSubclass: PolymorphicModuleBuilder.() -> Unit - - @Serializable - public data object AppInfo : Module { - override val section: Section - get() = Section.SETTINGS - - override val asSubclass: PolymorphicModuleBuilder.() -> Unit - get() = { - subclass(subclass = AppInfo::class, serializer = serializer()) - } - } - - @Serializable - public data object FeatureFlip : Module { - override val section: Section - get() = Section.FEATURES - - override val asSubclass: PolymorphicModuleBuilder.() -> Unit - get() = { - subclass(subclass = FeatureFlip::class, serializer = serializer()) - } - } - - @Serializable - public data object Console : Module { - override val section: Section - get() = Section.LOGGING - - override val subtitle: String - get() = "Logcat" - - override val asSubclass: PolymorphicModuleBuilder.() -> Unit - get() = { - subclass(subclass = Console::class, serializer = serializer()) - } - } - - @Serializable - public data object Analytics : Module { - override val section: Section - get() = Section.LOGGING - - override val subtitle: String - get() = "Firebase" - - override val asSubclass: PolymorphicModuleBuilder.() -> Unit - get() = { - subclass(subclass = Analytics::class, serializer = serializer()) - } - } - - @Serializable - public data object AppSpecific : Module { - override val section: Section - get() = Section.APP_SPECIFIC - - override val asSubclass: PolymorphicModuleBuilder.() -> Unit - get() = { - subclass(subclass = AppSpecific::class, serializer = serializer()) - } - } -} - -public enum class Section { - SETTINGS, - FEATURES, - LOGGING, - APP_SPECIFIC -} - -public val Section.icon: ImageVector - get() = when (this) { - Section.SETTINGS -> Icons.Rounded.Settings - Section.FEATURES -> Icons.Rounded.DeveloperMode - Section.LOGGING -> Icons.Rounded.FormatListNumbered - Section.APP_SPECIFIC -> Icons.Rounded.AppSettingsAlt - } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt new file mode 100644 index 00000000..9e41b757 --- /dev/null +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt @@ -0,0 +1,123 @@ +package com.worldline.devview.core + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Base interface for all DevView modules. + * Modules are metadata containers and don't participate in navigation directly. + * Navigation is handled through the module's destinations. + */ +public interface Module { + /** + * The name displayed in the module list. + * Defaults to the class simple name. + */ + public val moduleName: String + get() = this::class.simpleName ?: "UnknownModule" + + /** + * The section this module belongs to (for grouping). + */ + public val section: Section + + /** + * Icon displayed for this module. + * Defaults to the section icon. + */ + public val icon: ImageVector + get() = section.icon + + /** + * Background color of the icon container. + */ + public val containerColor: Color + get() = Color(color = 0xFF326EE6) + + /** + * Color of the icon itself. + */ + public val contentColor: Color + get() = Color(color = 0xFFE6E6E6) + + /** + * Optional subtitle displayed below the module name. + */ + public val subtitle: String? + get() = null + + /** + * List of all navigable destinations within this module. + * These are the NavKey objects that represent screens in this module. + */ + public val destinations: ImmutableList + + /** + * Register all destination serializers for navigation. + * Required for kotlinx.serialization polymorphism. + * + * Example: + * ``` + * override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = { + * subclass(MyDestination.Main::class, MyDestination.Main.serializer()) + * subclass(MyDestination.Detail::class, MyDestination.Detail.serializer()) + * } + * ``` + */ + public val registerSerializers: PolymorphicModuleBuilder.() -> Unit + + /** + * Register this module's composable content with the navigation entry provider. + * + * @param onNavigateBack Callback to navigate back (close current screen) + * @param onNavigate Callback to navigate forward to a destination + * + * Example: + * ``` + * override fun EntryProviderScope.registerContent( + * onNavigateBack: () -> Unit, + * onNavigate: (NavKey) -> Unit + * ) { + * entry { + * MainScreen( + * onNavigateBack = onNavigateBack, + * onItemClick = { id -> onNavigate(MyDestination.Detail(id)) } + * ) + * } + * entry { + * DetailScreen( + * onNavigateBack = onNavigateBack + * ) + * } + * } + * ``` + */ + public fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit + ) +} + +/** + * Helper function for creating preview modules. + * Internal use only for previews and testing. + */ +internal fun previewModule( + name: String = "PreviewModule", + section: Section = Section.CUSTOM +): Module = object : Module { + override val section: Section = section + override val moduleName: String = name + override val destinations: ImmutableList = kotlinx.collections.immutable + .persistentListOf() + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit + ) {} +} diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt new file mode 100644 index 00000000..2686d240 --- /dev/null +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt @@ -0,0 +1,64 @@ +package com.worldline.devview.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Builder for configuring DevView modules. + * Provides a clean DSL for users to compose their module list. + */ +public class ModuleRegistry { + private val modules = mutableListOf() + + /** + * Add a module to the DevView. + */ + public fun module(module: Module): ModuleRegistry = apply { + modules.add(element = module) + } + + /** + * Add multiple modules at once. + */ + public fun modules(vararg modules: Module): ModuleRegistry = apply { + this.modules.addAll(elements = modules) + } + + /** + * Build the final immutable list of modules. + */ + public fun build(): ImmutableList = modules.toImmutableList() +} + +/** + * DSL function for creating a module configuration. + * + * Example: + * ``` + * val modules = buildModules { + * module(AppInfo) + * module(FeatureFlip) + * module(MyCustomModule) + * } + * ``` + */ +public fun buildModules(block: ModuleRegistry.() -> Unit): ImmutableList = + ModuleRegistry().apply(block = block).build() + +/** + * Composable version that remembers the module list. + * + * Example: + * ``` + * val modules = rememberModules { + * module(AppInfo) + * module(FeatureFlip) + * } + * ``` + */ +@Composable +public fun rememberModules(block: ModuleRegistry.() -> Unit): ImmutableList = remember { + buildModules(block = block) +} diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/Poko.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/Poko.kt similarity index 74% rename from devview/src/commonMain/kotlin/com/worldline/devview/Poko.kt rename to devview/src/commonMain/kotlin/com/worldline/devview/core/Poko.kt index 916f2dcf..f6e4c555 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/Poko.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/Poko.kt @@ -1,4 +1,4 @@ -package com.worldline.devview +package com.worldline.devview.core @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.CLASS) diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/Section.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/Section.kt new file mode 100644 index 00000000..534e8628 --- /dev/null +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/Section.kt @@ -0,0 +1,23 @@ +package com.worldline.devview.core + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeveloperMode +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.FormatListNumbered +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.ui.graphics.vector.ImageVector + +public enum class Section { + SETTINGS, + FEATURES, + LOGGING, + CUSTOM +} + +public val Section.icon: ImageVector + get() = when (this) { + Section.SETTINGS -> Icons.Rounded.Settings + Section.FEATURES -> Icons.Rounded.DeveloperMode + Section.LOGGING -> Icons.Rounded.FormatListNumbered + Section.CUSTOM -> Icons.Rounded.Extension + } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt b/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt index 37f1d3a4..dd84372c 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt @@ -23,7 +23,9 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.worldline.devview.Module +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import com.worldline.devview.core.previewModule @Composable internal fun ModuleItem( @@ -65,7 +67,7 @@ internal fun ModuleItem( if (position.hasDivider) { HorizontalDivider( modifier = Modifier - .padding(start = 44.dp) // 24 (icon size) + 12 (padding end) + 8 (padding horizontal) + .padding(start = 44.dp) // 20 (icon size) + 12 (padding end) + 12 (padding horizontal) ) } Row( @@ -114,12 +116,18 @@ internal fun ModuleItem( private fun ModuleItemPreview() { Column { ModuleItem( - module = Module.FeatureFlip, + module = previewModule( + name = "Preview Module", + section = Section.SETTINGS + ), position = ModulePosition.FIRST, openModule = {} ) ModuleItem( - module = Module.Console, + module = previewModule( + name = "Preview Module 2", + section = Section.CUSTOM + ), position = ModulePosition.LAST, openModule = {} ) diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/SectionHeader.kt b/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/SectionHeader.kt deleted file mode 100644 index 9a02bb43..00000000 --- a/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/SectionHeader.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.worldline.devview.internal.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.worldline.devview.Section -import com.worldline.devview.icon - -@Composable -internal fun SectionHeader(section: Section, modifier: Modifier = Modifier) { - val backgroundColor = MaterialTheme.colorScheme.surface - - Row( - modifier = modifier - .fillMaxWidth() - .background(color = backgroundColor) - .padding( - vertical = 8.dp, - horizontal = 16.dp - ), - horizontalArrangement = Arrangement.spacedBy(space = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CompositionLocalProvider( - value = LocalContentColor provides contentColorFor(backgroundColor = backgroundColor) - ) { - Icon( - imageVector = section.icon, - contentDescription = null - ) - - Text( - text = when (section) { - Section.SETTINGS -> "Settings" - Section.FEATURES -> "Features" - Section.LOGGING -> "Logging" - Section.APP_SPECIFIC -> "App Specific" - }, - style = MaterialTheme.typography.titleMedium - ) - } - } -} diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts index 520c76a9..f1f2b8b6 100644 --- a/sample/androidApp/build.gradle.kts +++ b/sample/androidApp/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.convention.android.application) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.plugin.serialization) } android { @@ -25,12 +26,16 @@ android { dependencies { implementation(projects.sample.shared) implementation(projects.devview) + implementation(projects.devviewFeatureflip) implementation(libs.androidx.activity.compose) implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.androidx.navigation3.ui) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) debugImplementation(libs.jetbrains.compose.ui.tooling) debugImplementation(libs.jetbrains.compose.ui.tooling.preview) diff --git a/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/MainActivity.kt b/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/MainActivity.kt index f7b1adbb..8cbe8268 100644 --- a/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/MainActivity.kt +++ b/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/MainActivity.kt @@ -9,14 +9,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import com.worldline.devview.DevView -import com.worldline.devview.Module -import kotlinx.collections.immutable.persistentListOf +import com.worldline.devview.core.rememberModules +import com.worldline.devview.featureflip.FeatureFlip +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.LocalFeatureHandler +import com.worldline.devview.featureflip.model.rememberFeatureHandler +import kotlinx.coroutines.flow.first class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -24,39 +30,63 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { - val colorScheme = if (isSystemInDarkTheme()) { - darkColorScheme() - } else { - lightColorScheme() - } + val darkTheme = isSystemInDarkTheme() + + val featureHandler = rememberFeatureHandler( + features = listOf( + Feature.LocalFeature( + name = AppFeatures.DARK_MODE.featureName, + description = "Enable or disable dark mode", + isEnabled = darkTheme + ) + ) + ) - MaterialTheme( - colorScheme = colorScheme + CompositionLocalProvider( + LocalFeatureHandler provides featureHandler ) { - var devViewOpen by remember { mutableStateOf(false) } - App( - openDevView = { - devViewOpen = it + val localFeatureHandler = LocalFeatureHandler.current + + val darkMode by localFeatureHandler.isFeatureEnabled(AppFeatures.DARK_MODE.featureName) + + val colorScheme = if (darkMode) { + darkColorScheme() + } else { + lightColorScheme() + } + + MaterialTheme( + colorScheme = colorScheme + ) { + var devViewOpen by remember { mutableStateOf(false) } + App( + openDevView = { + devViewOpen = it + } + ) + + val modules = rememberModules { + module(FeatureFlip) + module(TestModule) } - ) - DevView( - openDevView = { devViewOpen }, - closeDevView = { - devViewOpen = false - }, - modules = persistentListOf( - Module.AppInfo, - Module.FeatureFlip, - Module.Console, - Module.Analytics, - Module.AppSpecific + + DevView( + devViewIsOpen = devViewOpen, + closeDevView = { + devViewOpen = false + }, + modules = modules ) - ) + } } } } } +private enum class AppFeatures(val featureName: String) { + DARK_MODE("Dark Mode") +} + @Preview @Composable fun AppAndroidPreview() { diff --git a/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/TestModule.kt b/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/TestModule.kt new file mode 100644 index 00000000..6e854e95 --- /dev/null +++ b/sample/androidApp/src/main/kotlin/com/worldline/devview/sample/TestModule.kt @@ -0,0 +1,90 @@ +package com.worldline.devview.sample + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +public sealed interface TestModuleNavigation: NavKey { + @Serializable + data object Main: TestModuleNavigation + + @Serializable + data object Detail: TestModuleNavigation +} + +public object TestModule : Module { + override val section: Section + get() = Section.CUSTOM + + override val destinations: ImmutableList = persistentListOf( + TestModuleNavigation.Main, + TestModuleNavigation.Detail + ) + + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit + get() = { + subclass( + subclass = TestModuleNavigation.Main::class, + serializer = TestModuleNavigation.Main.serializer() + ) + subclass( + subclass = TestModuleNavigation.Detail::class, + serializer = TestModuleNavigation.Detail.serializer() + ) + } + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit + ) { + entry { + Box( + modifier = Modifier + .fillMaxSize() + ) { + Text( + modifier = Modifier + .align(alignment = Alignment.Center), + text = "Main screen" + ) + Button( + onClick = { + onNavigate(TestModuleNavigation.Detail) + } + ) { + Text("Go to Detail") + } + } + } + entry { + Box( + modifier = Modifier + .fillMaxSize() + ) { + Text( + modifier = Modifier + .align(alignment = Alignment.Center), + text = "Detail screen" + ) + Button( + onClick = { + onNavigateBack() + } + ) { + Text("Go Back") + } + } + } + } +} \ No newline at end of file diff --git a/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/App.kt b/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/App.kt index a3a4e0f3..371a86d1 100644 --- a/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/App.kt @@ -3,7 +3,6 @@ package com.worldline.devview.sample import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -11,8 +10,6 @@ import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,46 +23,36 @@ import org.jetbrains.compose.resources.painterResource @Composable public fun App(modifier: Modifier = Modifier, openDevView: (Boolean) -> Unit) { - val colorScheme = if (isSystemInDarkTheme()) { - darkColorScheme() - } else { - lightColorScheme() - } - - MaterialTheme( - colorScheme = colorScheme + var showContent by remember { mutableStateOf(value = false) } + Column( + modifier = modifier + .background(color = MaterialTheme.colorScheme.primaryContainer) + .safeContentPadding() + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { - var showContent by remember { mutableStateOf(value = false) } - Column( - modifier = modifier - .background(color = MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Button(onClick = { showContent = !showContent }) { - Text(text = "Click me!") - } - AnimatedVisibility(visible = showContent) { - val greeting = remember { Greeting().greet() } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(resource = Res.drawable.compose_multiplatform), - contentDescription = null - ) - Text(text = "Compose: $greeting") - } - } - Button( - onClick = { - openDevView(true) - } + Button(onClick = { showContent = !showContent }) { + Text(text = "Click me!") + } + AnimatedVisibility(visible = showContent) { + val greeting = remember { Greeting().greet() } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Open DevView") + Image( + painter = painterResource(resource = Res.drawable.compose_multiplatform), + contentDescription = null + ) + Text(text = "Compose: $greeting") + } + } + Button( + onClick = { + openDevView(true) } + ) { + Text(text = "Open DevView") } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index bae268bc..a5294402 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ rootProject.name = "devview-root" include( ":devview", + ":devview-featureflip", ":internal:dokka", ":sample:androidApp", ":sample:shared"