From 4611634926a14e12978b947955ef0e1793044600 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Sep 2025 08:49:59 +0000 Subject: [PATCH 1/2] Initial plan From 4c291a11121f87ce62ffa0c4e1f14b861e4f4de6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Sep 2025 08:57:57 +0000 Subject: [PATCH 2/2] Implement selectAsStateFlow API with tests and documentation Co-authored-by: hoc081098 <36917223+hoc081098@users.noreply.github.com> --- README.md | 48 +++ .../hoc081098/flowext/selectAsStateFlow.kt | 159 ++++++++ .../flowext/SelectAsStateFlowTest.kt | 349 ++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 src/commonMain/kotlin/com/hoc081098/flowext/selectAsStateFlow.kt create mode 100644 src/commonTest/kotlin/com/hoc081098/flowext/SelectAsStateFlowTest.kt diff --git a/README.md b/README.md index 1c7e5bf9..078241b1 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ dependencies { - [`retryWithExponentialBackoff`](#retrywithexponentialbackoff) - [`scanWith`](#scanWith) - [`select`](#select) + - [`selectAsStateFlow`](#selectasstateflow) - [`skipUntil`](#skipuntil--dropuntil) - [`dropUntil`](#skipuntil--dropuntil) - [`takeUntil`](#takeuntil) @@ -1418,6 +1419,53 @@ select: [b] ---- +#### selectAsStateFlow + +- Combines the memoized selector functionality of [select](#select) with StateFlow conversion. +- Creates a hot, stateful flow that caches the latest selected value using [StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/). +- Perfect for UI state management where you need both the efficiency of memoized selectors and the benefits of StateFlow. + +```kotlin +// Example with coroutine scope +val scope = CoroutineScope(Dispatchers.Default) + +val stateFlow = MutableStateFlow( + UiState( + items = listOf("a", "b", "c"), + term = "a", + isLoading = false + ) +) + +// Create a StateFlow that selects and filters items +val filteredItemsStateFlow = stateFlow.selectAsStateFlow( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + selector1 = { it.items }, + selector2 = { it.term }, + projector = { items, term -> + items.filter { it.contains(term ?: "", ignoreCase = true) } + } +) + +// Access current value immediately +println("Current filtered items: ${filteredItemsStateFlow.value}") + +// Collect updates +filteredItemsStateFlow.collect { items -> + println("Filtered items updated: $items") +} +``` + +Benefits: +- **Memoized computation**: Selectors are only recomputed when input sub-states change +- **Hot StateFlow**: Always has the latest value available via `.value` +- **Configurable sharing**: Control when the StateFlow starts/stops with `SharingStarted` +- **Multiple overloads**: Support for 1-5 selectors with projector functions + +---- + #### skipUntil / dropUntil - ReactiveX docs: https://reactivex.io/documentation/operators/skipuntil.html diff --git a/src/commonMain/kotlin/com/hoc081098/flowext/selectAsStateFlow.kt b/src/commonMain/kotlin/com/hoc081098/flowext/selectAsStateFlow.kt new file mode 100644 index 00000000..500113b8 --- /dev/null +++ b/src/commonMain/kotlin/com/hoc081098/flowext/selectAsStateFlow.kt @@ -0,0 +1,159 @@ +/* + * MIT License + * + * Copyright (c) 2021-2024 Petrus Nguyễn Thái Học + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.hoc081098.flowext + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * Select a sub-state from the [State] and convert it to a [StateFlow]. + * + * This function combines the memoized selector functionality with StateFlow conversion, + * providing a hot, stateful flow that caches the latest selected value. + * + * @param scope The [CoroutineScope] in which sharing is started. + * @param started The strategy that controls when sharing is started and stopped. + * @param initialValue The initial value of the StateFlow. + * @param selector A function that takes the [State] and returns a sub-state. + */ +public fun Flow.selectAsStateFlow( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + initialValue: Result, + selector: Selector, +): StateFlow = select(selector).stateIn(scope, started, initialValue) + +/** + * Select two sub-states from the source [Flow], combine them into a [Result], and convert to a [StateFlow]. + * + * The [projector] will be invoked only when one of the sub-states is changed. + * The returned StateFlow will emit the result of [projector], + * and all subsequent results repetitions of the same value are filtered out. + * + * @param scope The [CoroutineScope] in which sharing is started. + * @param started The strategy that controls when sharing is started and stopped. + * @param initialValue The initial value of the StateFlow. + * @param selector1 The first selector to be used to select first sub-states. + * @param selector2 The second selector to be used to select second sub-states. + * @param projector The projector to be used to combine the sub-states into a result. + */ +public fun Flow.selectAsStateFlow( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + initialValue: Result, + selector1: Selector, + selector2: Selector, + projector: suspend (subState1: SubState1, subState2: SubState2) -> Result, +): StateFlow = select(selector1, selector2, projector).stateIn(scope, started, initialValue) + +/** + * Select three sub-states from the source [Flow], combine them into a [Result], and convert to a [StateFlow]. + * + * The [projector] will be invoked only when one of the sub-states is changed. + * The returned StateFlow will emit the result of [projector], + * and all subsequent results repetitions of the same value are filtered out. + * + * @param scope The [CoroutineScope] in which sharing is started and stopped. + * @param started The strategy that controls when sharing is started and stopped. + * @param initialValue The initial value of the StateFlow. + * @param selector1 The first selector to be used to select first sub-states. + * @param selector2 The second selector to be used to select second sub-states. + * @param selector3 The third selector to be used to select third sub-states. + * @param projector The projector to be used to combine the sub-states into a result. + */ +public fun Flow.selectAsStateFlow( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + initialValue: Result, + selector1: Selector, + selector2: Selector, + selector3: Selector, + projector: suspend (subState1: SubState1, subState2: SubState2, subState3: SubState3) -> Result, +): StateFlow = select(selector1, selector2, selector3, projector).stateIn(scope, started, initialValue) + +/** + * Select four sub-states from the source [Flow], combine them into a [Result], and convert to a [StateFlow]. + * + * The [projector] will be invoked only when one of the sub-states is changed. + * The returned StateFlow will emit the result of [projector], + * and all subsequent results repetitions of the same value are filtered out. + * + * @param scope The [CoroutineScope] in which sharing is started and stopped. + * @param started The strategy that controls when sharing is started and stopped. + * @param initialValue The initial value of the StateFlow. + * @param selector1 The first selector to be used to select first sub-states. + * @param selector2 The second selector to be used to select second sub-states. + * @param selector3 The third selector to be used to select third sub-states. + * @param selector4 The fourth selector to be used to select fourth sub-states. + * @param projector The projector to be used to combine the sub-states into a result. + */ +public fun Flow.selectAsStateFlow( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + initialValue: Result, + selector1: Selector, + selector2: Selector, + selector3: Selector, + selector4: Selector, + projector: suspend (subState1: SubState1, subState2: SubState2, subState3: SubState3, subState4: SubState4) -> Result, +): StateFlow = select(selector1, selector2, selector3, selector4, projector).stateIn(scope, started, initialValue) + +/** + * Select five sub-states from the source [Flow], combine them into a [Result], and convert to a [StateFlow]. + * + * The [projector] will be invoked only when one of the sub-states is changed. + * The returned StateFlow will emit the result of [projector], + * and all subsequent results repetitions of the same value are filtered out. + * + * @param scope The [CoroutineScope] in which sharing is started and stopped. + * @param started The strategy that controls when sharing is started and stopped. + * @param initialValue The initial value of the StateFlow. + * @param selector1 The first selector to be used to select first sub-states. + * @param selector2 The second selector to be used to select second sub-states. + * @param selector3 The third selector to be used to select third sub-states. + * @param selector4 The fourth selector to be used to select fourth sub-states. + * @param selector5 The fifth selector to be used to select fifth sub-states. + * @param projector The projector to be used to combine the sub-states into a result. + */ +public fun Flow.selectAsStateFlow( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + initialValue: Result, + selector1: Selector, + selector2: Selector, + selector3: Selector, + selector4: Selector, + selector5: Selector, + projector: suspend ( + subState1: SubState1, + subState2: SubState2, + subState3: SubState3, + subState4: SubState4, + subState5: SubState5, + ) -> Result, +): StateFlow = select(selector1, selector2, selector3, selector4, selector5, projector).stateIn(scope, started, initialValue) diff --git a/src/commonTest/kotlin/com/hoc081098/flowext/SelectAsStateFlowTest.kt b/src/commonTest/kotlin/com/hoc081098/flowext/SelectAsStateFlowTest.kt new file mode 100644 index 00000000..e5a4ff27 --- /dev/null +++ b/src/commonTest/kotlin/com/hoc081098/flowext/SelectAsStateFlowTest.kt @@ -0,0 +1,349 @@ +/* + * MIT License + * + * Copyright (c) 2021-2024 Petrus Nguyễn Thái Học + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.hoc081098.flowext + +import com.hoc081098.flowext.utils.BaseTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle + +@ExperimentalCoroutinesApi +private fun Flow.flowOnStandardTestDispatcher(testScope: TestScope): Flow = + flowOn(StandardTestDispatcher(testScope.testScheduler)) + +private data class UiState( + val isLoading: Boolean, + val items: List, + val searchTerm: String?, + val title: String, + val error: Throwable?, + val isRefreshing: Boolean, + val subtitle: String, + val unreadCount: Int, +) { + companion object { + val INITIAL = UiState( + isLoading = true, + items = emptyList(), + searchTerm = null, + title = "Loading...", + error = null, + isRefreshing = false, + subtitle = "Loading...", + unreadCount = 0, + ) + } +} + +private fun Flow.scanSkipFirst( + initial: R, + operation: suspend (acc: R, value: T) -> R, +): Flow = scan(initial, operation).drop(1) + +private val zeroToTen = List(10) { it.toString() } + +private val reducer: (acc: UiState, value: Int) -> UiState = { state, action -> + when (action) { + // items + 0 -> state.copy(items = zeroToTen) + // loading + 1 -> state.copy(isLoading = !state.isLoading) + // loading + 2 -> state.copy(isLoading = !state.isLoading) + // searchTerm + 3 -> state.copy(searchTerm = "4") + // loading + 4 -> state.copy(isLoading = !state.isLoading) + // items + 5 -> state.copy(items = state.items + "11") + // title + 6 -> state.copy(title = "Title") + // loading + 7 -> state.copy(isLoading = !state.isLoading) + // error + 8 -> state.copy(error = Throwable("Error")) + // subtitle + 9 -> state.copy(subtitle = "Subtitle") + // loading + 10 -> state.copy(isLoading = !state.isLoading) + // unreadCount + 11 -> state.copy(unreadCount = state.unreadCount + 1) + // isRefreshing + 12 -> state.copy(isRefreshing = !state.isRefreshing) + // subtitle + 13 -> state.copy(subtitle = "Subtitle 2") + // isRefreshing + 14 -> state.copy(isRefreshing = !state.isRefreshing) + // unreadCount + 15 -> state.copy(unreadCount = state.unreadCount + 1) + else -> error("Unknown action") + } +} + +@ExperimentalCoroutinesApi +class SelectAsStateFlow1Test : BaseTest() { + @Test + fun testSelectAsStateFlow1() = runTest { + val stateFlow = MutableStateFlow(UiState.INITIAL) + + val selectedStateFlow = stateFlow.selectAsStateFlow( + scope = this, + started = SharingStarted.Eagerly, + initialValue = 0, + selector = { it.items.size }, + ) + + // Check initial value + assertEquals(0, selectedStateFlow.value) + + // Update state + stateFlow.value = stateFlow.value.copy(items = zeroToTen) + advanceUntilIdle() + + // Check updated value + assertEquals(10, selectedStateFlow.value) + + // Update with same value - should not emit + stateFlow.value = stateFlow.value.copy(items = zeroToTen) + advanceUntilIdle() + + // Should still be 10 + assertEquals(10, selectedStateFlow.value) + } + + @Test + fun testSelectAsStateFlow1WithFlow() = runTest { + val selectedStateFlow = (0..5).asFlow() + .flowOnStandardTestDispatcher(this) + .onEach { delay(100) } + .scanSkipFirst(UiState.INITIAL, reducer) + .selectAsStateFlow( + scope = this, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + selector = { it.items }, + ) + + // Check initial value + assertEquals(emptyList(), selectedStateFlow.value) + + advanceUntilIdle() + + // Check final value after all emissions + assertEquals(zeroToTen + "11", selectedStateFlow.value) + } +} + +@ExperimentalCoroutinesApi +class SelectAsStateFlow2Test : BaseTest() { + @Test + fun testSelectAsStateFlow2() = runTest { + val stateFlow = MutableStateFlow(UiState.INITIAL) + + val selectedStateFlow = stateFlow.selectAsStateFlow( + scope = this, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + selector1 = { it.searchTerm }, + selector2 = { it.items }, + projector = { searchTerm, items -> + items.filter { it.contains(searchTerm ?: "") } + }, + ) + + // Check initial value + assertEquals(emptyList(), selectedStateFlow.value) + + // Update state with items + stateFlow.value = stateFlow.value.copy(items = zeroToTen) + advanceUntilIdle() + + // Should contain all items (empty search term matches all) + assertEquals(zeroToTen, selectedStateFlow.value) + + // Update search term + stateFlow.value = stateFlow.value.copy(searchTerm = "4") + advanceUntilIdle() + + // Should only contain item "4" + assertEquals(listOf("4"), selectedStateFlow.value) + } +} + +@ExperimentalCoroutinesApi +class SelectAsStateFlow3Test : BaseTest() { + @Test + fun testSelectAsStateFlow3() = runTest { + val stateFlow = MutableStateFlow(UiState.INITIAL) + + val selectedStateFlow = stateFlow.selectAsStateFlow( + scope = this, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + selector1 = { it.searchTerm }, + selector2 = { it.items }, + selector3 = { it.title }, + projector = { searchTerm, items, title -> + items + .filter { it.contains(searchTerm ?: "") } + .map { "$it # $title" } + }, + ) + + // Check initial value + assertEquals(emptyList(), selectedStateFlow.value) + + // Update state + stateFlow.value = stateFlow.value.copy( + items = zeroToTen, + searchTerm = "4", + title = "MyTitle", + ) + advanceUntilIdle() + + assertEquals(listOf("4 # MyTitle"), selectedStateFlow.value) + } +} + +@ExperimentalCoroutinesApi +class SelectAsStateFlow4Test : BaseTest() { + @Test + fun testSelectAsStateFlow4() = runTest { + val stateFlow = MutableStateFlow(UiState.INITIAL) + + val selectedStateFlow = stateFlow.selectAsStateFlow( + scope = this, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + selector1 = { it.searchTerm }, + selector2 = { it.items }, + selector3 = { it.title }, + selector4 = { it.subtitle }, + projector = { searchTerm, items, title, subtitle -> + items + .filter { it.contains(searchTerm ?: "") } + .map { "$it # $title ~ $subtitle" } + }, + ) + + // Check initial value + assertEquals(emptyList(), selectedStateFlow.value) + + // Update state + stateFlow.value = stateFlow.value.copy( + items = zeroToTen, + searchTerm = "4", + title = "MyTitle", + subtitle = "MySubtitle", + ) + advanceUntilIdle() + + assertEquals(listOf("4 # MyTitle ~ MySubtitle"), selectedStateFlow.value) + } +} + +@ExperimentalCoroutinesApi +class SelectAsStateFlow5Test : BaseTest() { + @Test + fun testSelectAsStateFlow5() = runTest { + val stateFlow = MutableStateFlow(UiState.INITIAL) + + val selectedStateFlow = stateFlow.selectAsStateFlow( + scope = this, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + selector1 = { it.searchTerm }, + selector2 = { it.items }, + selector3 = { it.title }, + selector4 = { it.subtitle }, + selector5 = { it.unreadCount }, + projector = { searchTerm, items, title, subtitle, unreadCount -> + items + .filter { it.contains(searchTerm ?: "") } + .map { "$it # $title ~ $subtitle $ $unreadCount" } + }, + ) + + // Check initial value + assertEquals(emptyList(), selectedStateFlow.value) + + // Update state + stateFlow.value = stateFlow.value.copy( + items = zeroToTen, + searchTerm = "4", + title = "MyTitle", + subtitle = "MySubtitle", + unreadCount = 5, + ) + advanceUntilIdle() + + assertEquals(listOf("4 # MyTitle ~ MySubtitle $ 5"), selectedStateFlow.value) + } +} + +@ExperimentalCoroutinesApi +class SelectAsStateFlowSharingTest : BaseTest() { + @Test + fun testSelectAsStateFlowSharingLazily() = runTest { + val stateFlow = MutableStateFlow(UiState.INITIAL) + var selectorCallCount = 0 + + val selectedStateFlow = stateFlow.selectAsStateFlow( + scope = this, + started = SharingStarted.Lazily, + initialValue = 0, + selector = { + selectorCallCount++ + it.items.size + }, + ) + + // No collection yet, selector shouldn't be called + assertEquals(0, selectorCallCount) + assertEquals(0, selectedStateFlow.value) // Initial value + + // Start collecting + val collector = selectedStateFlow.replayCache + + // Update state + stateFlow.value = stateFlow.value.copy(items = zeroToTen) + advanceUntilIdle() + + // Now selector should be called and value updated + assertEquals(10, selectedStateFlow.value) + } +}