From 23ed53fb42c152fd903494c018df0255654f869b Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Fri, 10 Jul 2026 15:18:58 +0200 Subject: [PATCH 1/2] :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) { From fb66b11f2a6cce744d8c6397ac44d2ebb81e54ab Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 15 Jul 2026 11:55:43 +0200 Subject: [PATCH 2/2] :memo: Update docs for type badge and filter logic changes Co-Authored-By: Claude Opus 4.6 --- devview-featureflip/CLAUDE.md | 3 ++- devview-featureflip/README.md | 4 ++++ .../com/worldline/devview/featureflip/FeatureFlipScreen.kt | 6 +++--- .../worldline/devview/featureflip/components/FeatureItem.kt | 3 ++- gradle.properties | 3 +++ 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/devview-featureflip/CLAUDE.md b/devview-featureflip/CLAUDE.md index ef1774d..fb316cb 100644 --- a/devview-featureflip/CLAUDE.md +++ b/devview-featureflip/CLAUDE.md @@ -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. diff --git a/devview-featureflip/README.md b/devview-featureflip/README.md index 5e370f4..92473ff 100644 --- a/devview-featureflip/README.md +++ b/devview-featureflip/README.md @@ -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 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 cb116de..3609006 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 @@ -346,9 +346,9 @@ 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 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 97511a5..d62b8e8 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 @@ -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) diff --git a/gradle.properties b/gradle.properties index 6d08371..fcab62f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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