From 23ed53fb42c152fd903494c018df0255654f869b Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Fri, 10 Jul 2026 15:18:58 +0200 Subject: [PATCH] :sparkles: Add feature type badge and fix filter logic in FeatureFlip - Display a "Local" / "Remote" label on each FeatureItem for at-a-glance type identification (closes #25) - Fix filter bug: type chips (LOCAL/REMOTE) and state chips (ON/OFF) now OR within their dimension and AND across, so selecting both LOCAL and REMOTE correctly shows all features instead of none - Extend availableEntries to hide type chips when all features are local (was remote-only) Co-Authored-By: Claude Opus 4.6 --- .../featureflip/FeatureFlipScreenTest.kt | 47 ++++++++++++++++ .../devview/featureflip/FeatureFlipScreen.kt | 54 +++++++++---------- .../featureflip/components/FeatureItem.kt | 9 ++++ 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt b/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt index cba04fa..d47630b 100644 --- a/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt +++ b/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt @@ -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 @@ -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( 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 index 1fa84a0..cb116de 100644 --- a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt @@ -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 } } } @@ -357,11 +354,10 @@ private enum class FeatureFilter { * @return List of applicable filter options */ fun availableEntries(features: List): List = - if (features.filterIsInstance().size == features.size) { - listOf( - ON, - OFF - ) + if (features.all { it is Feature.RemoteFeature } || + features.all { it is Feature.LocalFeature } + ) { + listOf(ON, OFF) } else { entries } 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 index bf3e249..97511a5 100644 --- 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 @@ -94,6 +94,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) {