diff --git a/README.md b/README.md index 12e1372..816e3a1 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ dependencies { } ``` -> Replace `` with the version from your `build.gradle.kts`. +> Replace `` with the version from your `build.gradle.kts`. Currently 1.0.0-alpha01 --- @@ -89,11 +89,12 @@ A sample Jetpack Compose app with: ### StatelyFetch ```kotlin -val fetch = StatelyFetch( +val fetch = StatelyFetch( fetcher = { payload -> api.loadData(payload) }, revalidateInterval = 5000L, lazy = false, - initialData = null + initialData = null, + initialPayload = "", ) ``` @@ -163,13 +164,6 @@ StatelyFetchBoundary( --- -## Roadmap - -- [ ] Auto cancellation -- [ ] Debouncing - ---- - ## Contributing This library is still under active development. PRs and feedback are welcome. diff --git a/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchBoundaryExample.kt b/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchBoundaryExample.kt index f63c56b..831a764 100644 --- a/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchBoundaryExample.kt +++ b/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchBoundaryExample.kt @@ -25,8 +25,8 @@ fun StatelyFetchBoundaryExampleScreen() { .padding(padding) .padding(16.dp) ) { - StatelyFetchBoundary( - fetcher = { + StatelyFetchBoundary( + fetcher = { payload -> delay(1500) "Data from StatelyFetchBoundary" }, diff --git a/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchContentExample.kt b/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchContentExample.kt index c746544..2306316 100644 --- a/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchContentExample.kt +++ b/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchContentExample.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.delay @Composable fun StatelyFetchContentExampleScreen() { val scope = rememberCoroutineScope() - val statelyFetch = rememberStatelyFetch( + val statelyFetch = rememberStatelyFetch( fetcher = { delay(2000) "This is data from StatelyFetchContent" diff --git a/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchExample.kt b/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchExample.kt index 3320b7b..28cd4d6 100644 --- a/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchExample.kt +++ b/sample/src/commonMain/kotlin/dev/voir/stately/sample/examples/StatelyFetchExample.kt @@ -45,6 +45,8 @@ fun StatelyFetchExampleScreen() { var currentConfig by remember { mutableStateOf(null) } var simulationVersion by remember { mutableIntStateOf(0) } + var payloadInput by remember { mutableStateOf(null) } + Scaffold( topBar = { TopAppBar(title = { Text("StatelyFetch Example") }) @@ -94,13 +96,24 @@ fun StatelyFetchExampleScreen() { modifier = Modifier.fillMaxWidth() ) + OutlinedTextField( + value = payloadInput.orEmpty(), + onValueChange = { + payloadInput = it.ifBlank { null } + }, + label = { Text("Initial payload value") }, + keyboardOptions = KeyboardOptions.Default, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { currentConfig = FetchConfig( simulateError = simulateError, lazy = lazy, initiallyLoading = initiallyLoading, revalidateInterval = revalidateIntervalInput, - initialData = initialData + initialData = initialData, + payload = payloadInput ) simulationVersion++ }) { @@ -121,21 +134,22 @@ fun StatelyFetchExampleScreen() { @Composable private fun FetchSimulation(config: FetchConfig) { - var counter by remember(config) { mutableIntStateOf(0) } - val scope = rememberCoroutineScope() - val statelyFetch = rememberStatelyFetch( - fetcher = { + var currentPayload by remember(config) { mutableStateOf(config.payload) } + + val statelyFetch = rememberStatelyFetch( + fetcher = { payload -> delay(3000) // simulate network delay if (config.simulateError) throw Exception("Simulated Error") - "Fetched Data: ${counter++}" + "Fetched Data (payload=${payload ?: "none"})" }, scope = scope, revalidateInterval = config.revalidateInterval, initiallyLoading = config.initiallyLoading, initialData = config.initialData, - lazy = config.lazy + lazy = config.lazy, + initialPayload = config.payload ) val state by statelyFetch.state.collectAsState() @@ -144,11 +158,33 @@ private fun FetchSimulation(config: FetchConfig) { Text("Loading: ${state.loading}") Text("Data: ${state.data ?: "null"}") Text("Error: ${state.error?.message ?: "none"}") + + HorizontalDivider() + Button(onClick = { statelyFetch.revalidate() }) { Text("Fetch/revalidate") } + + Button(onClick = { + statelyFetch.revalidateDebounced(debounce = 3000) + }) { + Text("Fetch/revalidate with debounce") + } + + HorizontalDivider() + + + OutlinedTextField( + value = currentPayload.orEmpty(), + onValueChange = { + currentPayload = it.ifBlank { null } + statelyFetch.revalidate(it.ifBlank { null }) // triggers fetch + tracks new payload + }, + label = { Text("Change Payload") }, + modifier = Modifier.fillMaxWidth() + ) } private data class FetchConfig( @@ -156,5 +192,6 @@ private data class FetchConfig( val lazy: Boolean, val initiallyLoading: Boolean, val revalidateInterval: Long?, - val initialData: String? + val initialData: String?, + val payload: String? ) diff --git a/stately/src/commonMain/kotlin/dev/voir/stately/StatelyFetch.kt b/stately/src/commonMain/kotlin/dev/voir/stately/StatelyFetch.kt index a1fb047..1896dc0 100644 --- a/stately/src/commonMain/kotlin/dev/voir/stately/StatelyFetch.kt +++ b/stately/src/commonMain/kotlin/dev/voir/stately/StatelyFetch.kt @@ -3,11 +3,22 @@ package dev.voir.stately import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +/** + * Represents the current result state of a fetch operation. + * + * @param Data The type of data being fetched. + * @param Payload The type of payload used to trigger the fetch. + * @property payload The payload used in the current request. + * @property data The successfully fetched data (null if failed or not yet loaded). + * @property loading Whether a fetch is currently in progress. + * @property error The exception thrown during fetch (if any). + */ data class StatelyFetchResult( val payload: Payload?, val data: Data?, @@ -15,6 +26,18 @@ data class StatelyFetchResult( val error: Throwable? = null, ) +/** + * A utility class for managing async data fetching with automatic state tracking, + * manual and debounced revalidation, and optional interval-based refresh. + * + * @param fetcher A suspending function that performs the data fetch, accepting a payload. + * @param scope The coroutine scope to launch fetch operations in (defaults to IO). + * @param revalidateInterval If provided, will re-trigger fetch on this interval in ms. + * @param lazy If true, the fetch will not start automatically on creation. + * @param initialData Optional initial data to display before fetching. + * @param initialPayload Optional payload to use for the initial fetch. + * @param initiallyLoading Whether to mark the state as loading immediately. + */ class StatelyFetch( private val fetcher: suspend (payload: Payload?) -> Data, private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), @@ -24,7 +47,20 @@ class StatelyFetch( initialPayload: Payload? = null, initiallyLoading: Boolean = true, ) { + // True if a fetch operation is in progress. private var revalidating: Boolean = false + + // The current active fetch coroutine (cancellable). + private var fetchJob: Job? = null + + + // The current scheduled debounce job (used to delay fetch execution). + private var debounceJob: Job? = null + + // The most recent payload used or queued for fetch. + private var payload = initialPayload + + // Backing state flow for consumers to observe fetch result updates. private val _state = MutableStateFlow( StatelyFetchResult( payload = initialPayload, @@ -32,41 +68,87 @@ class StatelyFetch( data = initialData ) ) + + /** + * Public immutable state flow exposing the fetch result. + * Observers can collect this to react to loading, error, and data changes. + */ val state: StateFlow> get() = _state init { + // Trigger initial fetch immediately unless lazy is true. if (!lazy) { - revalidate() + revalidate(initialPayload) } + // If a revalidateInterval is set, start a loop that periodically refetches. if (revalidateInterval != null) { scope.launch { while (true) { delay(revalidateInterval) - revalidate() + revalidate(payload) } } } } - fun revalidate(payload: Payload? = null) { - if (!revalidating) { + /** + * Immediately revalidates the data with an optional payload override. + * Cancels any pending debounce job and any in-flight fetch. + * + * @param payload The payload to use for this fetch (defaults to last used). + */ + fun revalidate(payload: Payload? = this.payload) { + debounceJob?.cancel() + + this.payload = payload + doRevalidate(payload) + } + + + /** + * Schedules a debounced revalidation. If called again before the debounce delay, + * the previous job is cancelled and the timer restarts. + * + * Useful for user input scenarios (e.g., text field filters). + * + * @param payload The payload to use (defaults to current). + * @param debounce How long to wait (in milliseconds) before triggering fetch. + */ + fun revalidateDebounced(payload: Payload? = this.payload, debounce: Long = 1000) { + this.payload = payload + debounceJob?.cancel() + debounceJob = scope.launch { + if (debounce > 0) { + delay(debounce) + } doRevalidate(payload) } } + /** + * Launches a fetch coroutine and updates state accordingly. + * Cancels any currently running fetch. + * + * @param payload The payload to pass to the fetcher function. + */ private fun doRevalidate(payload: Payload? = null) { - revalidating = true + fetchJob?.cancel() // Ensure only one fetch at a time. + fetchJob = scope.launch { + revalidating = true - scope.launch { + // Mark state as loading with new payload. _state.value = state.value.copy(loading = true, error = null, payload = payload) + try { + // Perform the fetch. val data = fetcher(payload) + // Update state with result. _state.value = StatelyFetchResult(payload = payload, data = data, loading = false) } catch (e: Exception) { e.printStackTrace() - // Keep previous data and payload on error + // On error, retain previous data and set error field. _state.value = _state.value.copy(error = e, loading = false) } finally { revalidating = false diff --git a/stately/src/commonMain/kotlin/dev/voir/stately/rememberStatelyFetch.kt b/stately/src/commonMain/kotlin/dev/voir/stately/rememberStatelyFetch.kt index 061875f..2c9b306 100644 --- a/stately/src/commonMain/kotlin/dev/voir/stately/rememberStatelyFetch.kt +++ b/stately/src/commonMain/kotlin/dev/voir/stately/rememberStatelyFetch.kt @@ -8,6 +8,28 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +@Composable +fun rememberStatelyFetch( + fetcher: suspend (payload: Payload?) -> Data, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + revalidateInterval: Long? = null, + initiallyLoading: Boolean = true, + lazy: Boolean = false, + initialData: Data? = null, + initialPayload: Payload? = null, + autoRevalidateOnPayloadChange: Boolean = true, +) = remember { + StatelyFetch( + fetcher = fetcher, + scope = scope, + revalidateInterval = revalidateInterval, + initialData = initialData, + initiallyLoading = initiallyLoading, + initialPayload = initialPayload, + lazy = lazy, + ) +} + @Composable fun rememberStatelyFetch( fetcher: suspend () -> Data, @@ -17,13 +39,16 @@ fun rememberStatelyFetch( lazy: Boolean = false, initialData: Data? = null, ) = remember { - StatelyFetch( - fetcher = { fetcher() }, + StatelyFetch( + fetcher = { + fetcher() + }, scope = scope, revalidateInterval = revalidateInterval, initialData = initialData, initiallyLoading = initiallyLoading, - lazy = lazy + initialPayload = null, + lazy = lazy, ) } diff --git a/stately/src/commonMain/kotlin/dev/voir/stately/ui/StatelyFetchBoundary.kt b/stately/src/commonMain/kotlin/dev/voir/stately/ui/StatelyFetchBoundary.kt index 56b16fd..83f7e4e 100644 --- a/stately/src/commonMain/kotlin/dev/voir/stately/ui/StatelyFetchBoundary.kt +++ b/stately/src/commonMain/kotlin/dev/voir/stately/ui/StatelyFetchBoundary.kt @@ -9,18 +9,28 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @Composable -fun StatelyFetchBoundary( - fetcher: suspend () -> Data, - loading: @Composable (() -> Unit)? = null, - error: @Composable ((Throwable) -> Unit)? = null, +fun StatelyFetchBoundary( + fetcher: suspend (payload: Payload?) -> Data, scope: CoroutineScope = CoroutineScope(Dispatchers.IO), revalidateInterval: Long? = null, + initiallyLoading: Boolean = true, + lazy: Boolean = false, + initialData: Data? = null, + initialPayload: Payload? = null, + autoRevalidateOnPayloadChange: Boolean = true, + loading: @Composable (() -> Unit)? = null, + error: @Composable ((Throwable) -> Unit)? = null, content: @Composable (data: Data) -> Unit ) { val state by rememberStatelyFetch( fetcher = fetcher, scope = scope, revalidateInterval = revalidateInterval, + initialData = initialData, + initiallyLoading = initiallyLoading, + initialPayload = initialPayload, + lazy = lazy, + autoRevalidateOnPayloadChange = autoRevalidateOnPayloadChange, ).collectAsState() StatelyFetchContent(