Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion devview-featureflip/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ featureHandler.isFeatureEnabledFlow("dark_mode").collect { … }

- **`FeatureState` ordinals are load-bearing.** They are persisted as raw integers in DataStore (`REMOTE=0`, `LOCAL_OFF=1`, `LOCAL_ON=2`). Reordering the enum entries is a breaking data migration.
- **`FeatureTriStateSwitch` segment order mirrors the enum ordinal order.** The `selectedIndex` is derived directly from `feature.state.ordinal`. Keep enum and UI in sync.
- **`FeatureFilter` is adaptive.** If every registered feature is a `RemoteFeature`, the LOCAL/REMOTE filter chips are hidden; only ON/OFF chips appear.
- **`FeatureFilter` is adaptive.** If all registered features are the same type (all `RemoteFeature` or all `LocalFeature`), the LOCAL/REMOTE filter chips are hidden; only ON/OFF chips appear.
- **Filter chips use OR within a dimension, AND across dimensions.** Selecting LOCAL+REMOTE shows all features; selecting LOCAL+ON shows only enabled local features.
- **`Poko` annotation** is applied via `com.worldline.devview.core.Poko` (project-local alias), not the default `@Poko`. The `Feature` sealed class members use standard `data class`, so `Poko` affects other model classes in the wider project.
- **`FeatureHandler.featureRegistry` keyed by `Feature` instance.** Lookups for reads/writes use `firstOrNull { it.key.name == featureName }` — feature `name` is the stable identity; the `Feature` object itself (including `isEnabled`/`state`) changes as DataStore emits updates.
- **`SegmentedButtonContentMeasurePolicy`** is a hand-rolled copy of Material3 internal logic, required because M3 does not expose icon-only segmented buttons with per-segment container colors.
4 changes: 4 additions & 0 deletions devview-featureflip/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,16 @@ The main screen component that displays all features with search and filter capa

### Feature Controls

Each feature item displays a **type label** ("Local" or "Remote") so the feature's origin is visible at a glance without relying solely on the control widget.

- **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

Filter chips use **OR within a dimension, AND across dimensions** — selecting both LOCAL and REMOTE shows all features, while selecting LOCAL and ON narrows to enabled local features only. The LOCAL/REMOTE chips are hidden automatically when all features are the same type.

## API Reference

### Core Types
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.worldline.devview.featureflip

import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
Expand Down Expand Up @@ -106,6 +107,52 @@ class FeatureFlipScreenTest {
waitUntilTagCount(tag = "feature_item_new_checkout", expectedCount = 0)
}

@Test
fun featureFlipScreen_type_badges_are_displayed() = runComposeUiTest {
val handler = FeatureHandler(
dataStore = FakePreferencesDataStore(),
initialFeatures = listOf(
Feature.LocalFeature("dark_mode", null, false),
Feature.RemoteFeature("new_checkout", null, true, FeatureState.REMOTE)
)
)

setContent {
CompositionLocalProvider(LocalFeatureHandler provides handler) {
FeatureFlipScreen()
}
}

waitUntilTagCount(tag = "feature_item_dark_mode", expectedCount = 1)
waitUntilTagCount(tag = "feature_item_new_checkout", expectedCount = 1)

onNodeWithTag(testTag = "feature_type_dark_mode").assertIsDisplayed()
onNodeWithTag(testTag = "feature_type_new_checkout").assertIsDisplayed()
}

@Test
fun featureFlipScreen_selecting_local_and_remote_chips_shows_all_features() = runComposeUiTest {
val handler = FeatureHandler(
dataStore = FakePreferencesDataStore(),
initialFeatures = listOf(
Feature.LocalFeature("dark_mode", null, false),
Feature.RemoteFeature("new_checkout", null, true, FeatureState.REMOTE)
)
)

setContent {
CompositionLocalProvider(LocalFeatureHandler provides handler) {
FeatureFlipScreen()
}
}

onNodeWithTag(testTag = "feature_filter_chip_LOCAL").performClick()
onNodeWithTag(testTag = "feature_filter_chip_REMOTE").performClick()

waitUntilTagCount(tag = "feature_item_dark_mode", expectedCount = 1)
waitUntilTagCount(tag = "feature_item_new_checkout", expectedCount = 1)
}

@Test
fun featureFlipScreen_clear_filter_icon_visibility_and_behavior() = runComposeUiTest {
val handler = FeatureHandler(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,37 +137,34 @@ public fun FeatureFlipScreen(modifier: Modifier = Modifier, bottomPadding: Dp =

val filteredFeatures by remember(key1 = filterQuery, key2 = selectedFilters, key3 = features) {
derivedStateOf {
when {
filterQuery.isBlank() && selectedFilters.values.all { !it } -> features
val activeTypeFilters = selectedFilters
.filterKeys { it == FeatureFilter.LOCAL || it == FeatureFilter.REMOTE }
.filterValues { it }
.keys
val activeStateFilters = selectedFilters
.filterKeys { it == FeatureFilter.ON || it == FeatureFilter.OFF }
.filterValues { it }
.keys

selectedFilters.values.all { !it } -> features.filter { feature ->
features.filter { feature ->
val matchesQuery = filterQuery.isBlank() ||
feature.name.contains(other = filterQuery, ignoreCase = true)
}

filterQuery.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
val matchesType = activeTypeFilters.isEmpty() || activeTypeFilters.any { filter ->
when (filter) {
FeatureFilter.LOCAL -> feature is Feature.LocalFeature
FeatureFilter.REMOTE -> feature is Feature.RemoteFeature
else -> false
}
}

else -> features.filter { feature ->
feature.name.contains(
other = filterQuery,
ignoreCase = true
) && selectedFilters.filter { it.value }.all { (state, selected) ->
when (state) {
FeatureFilter.LOCAL -> feature is Feature.LocalFeature
FeatureFilter.REMOTE -> feature is Feature.RemoteFeature
val matchesState =
activeStateFilters.isEmpty() || activeStateFilters.any { filter ->
when (filter) {
FeatureFilter.ON -> feature.isEnabled
FeatureFilter.OFF -> !feature.isEnabled
} && selected
else -> false
}
}
}
matchesQuery && matchesType && matchesState
}
}
}
Expand Down Expand Up @@ -349,19 +346,18 @@ private enum class FeatureFilter {
/**
* Returns the available filter entries based on the feature list composition.
*
* If all features are remote features, only ON/OFF filters are shown
* (since LOCAL/REMOTE filtering would be redundant). Otherwise, all
* filter types are available.
* If all features are the same type (all remote or all local), only ON/OFF
* filters are shown since LOCAL/REMOTE filtering would be redundant.
* Otherwise, all filter types are available.
*
* @param features The list of features to analyze
* @return List of applicable filter options
*/
fun availableEntries(features: List<Feature>): List<FeatureFilter> =
if (features.filterIsInstance<Feature.RemoteFeature>().size == features.size) {
listOf(
ON,
OFF
)
if (features.all { it is Feature.RemoteFeature } ||
features.all { it is Feature.LocalFeature }
) {
listOf(ON, OFF)
} else {
entries
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ import com.worldline.devview.featureflip.preview.FeaturePreviewParameterProvider
* └── Row
* ├── Column (feature info, weighted)
* │ ├── Text (feature name, bold)
* │ └── Text (description, optional)
* │ ├── Text (description, optional)
* │ └── Text (type label: "Local" or "Remote")
* └── Control Widget
* ├── Switch (for LocalFeature)
* └── FeatureTriStateSwitch (for RemoteFeature)
Expand Down Expand Up @@ -94,6 +95,15 @@ internal fun FeatureItem(
style = MaterialTheme.typography.bodySmall
)
}
Text(
modifier = Modifier.testTag(tag = "feature_type_${feature.name}"),
text = when (feature) {
is LocalFeature -> "Local"
is RemoteFeature -> "Remote"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}

when (feature) {
Expand Down
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ POM_LICENCE_DIST=repo

POM_DEVELOPER_ID=MaxMichel2
POM_DEVELOPER_NAME=Maxime Michel

# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true