All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.copperleaf.ballast.repository.cache.FetchWithCache.kt Maven / Gradle / Ivy

There is a newer version: 4.2.1
Show newest version
package com.copperleaf.ballast.repository.cache

import com.copperleaf.ballast.InputHandlerScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

/**
 * Populates a cached value in a Repository ViewModel. The remote data source ([doFetch]) will only be called if the
 * current state is [NotLoaded] or [FetchingFailed].
 *
 * When [forceRefresh] is true, the state will initially be reset back to [NotLoaded], but will retain the previous
 * value in the cache. Thus, as this method runs and the state of the property changes, the UI will continue showing
 * the stale data until the remote fetch has completed or failed. This has the effect of allowing the UI to show a
 * progress indicator over the previous content, rather than removing the previous content and showing the progress
 * indicator in its place. This just makes a nicer user experience, where there is less UI "jank" when refreshing data.
 *
 * TODO: maybe add retry logic in here, to help deal with potentially flaky APIs?
 */
public suspend fun  InputHandlerScope.fetchWithCache(
    input: Inputs,
    forceRefresh: Boolean,
    matchesPrerequisites: (State) -> Boolean = { true },
    getValue: (State) -> Cached,
    updateState: suspend (Cached) -> Inputs,
    doFetch: suspend (State) -> Property,
) {
    // VM is not ready to start fetching yet
    val currentState = getCurrentState()
    if (!matchesPrerequisites(currentState)) return

    // VM is already fetching when another request came in. If the second request is a forced refresh, cancel the first
    // by restarting the side-job. If the second reqeust is not a forced refresh, return and allow the original to
    // continue executing.
    if (getValue(currentState) is Cached.Fetching && !forceRefresh) return

    sideJob(input::class.simpleName!!) {
        val initialValue = getValue(currentStateWhenStarted)
        val currentValueUnboxed = initialValue.getCachedOrNull()
        val currentValue = if (forceRefresh) {
            // if forcing a refresh, first mark it as not loaded (but keep the previous value for a better UI experience
            // when re-fetching)
            Cached.NotLoaded(currentValueUnboxed).also { postInput(updateState(it)) }
        } else {
            // otherwise, use the existing value as the current cached value
            initialValue
        }

        // if we have not loaded yet, have requested a forced refresh, or the previous attempt to fetch failed, try fetching
        // from the remote source now
        if (currentValue is Cached.NotLoaded || currentValue is Cached.FetchingFailed) {
            postInput(updateState(Cached.Fetching(currentValueUnboxed)))

            val result = try {
                coroutineScope { Cached.Value(doFetch(currentStateWhenStarted)) }
            } catch (t: Throwable) {
                Cached.FetchingFailed(t, currentValueUnboxed)
            }

            postInput(updateState(result))
        }
    }
}

/**
 * Populates a cached value in a Repository ViewModel. The remote data source ([doFetch]) will only be called if the
 * current state is [NotLoaded] or [FetchingFailed].
 *
 * When [forceRefresh] is true, the state will initially be reset back to [NotLoaded], but will retain the previous
 * value in the cache. Thus, as this method runs and the state of the property changes, the UI will continue showing
 * the stale data until the remote fetch has completed or failed. This has the effect of allowing the UI to show a
 * progress indicator over the previous content, rather than removing the previous content and showing the progress
 * indicator in its place. This just makes a nicer user experience, where there is less UI "jank" when refreshing data.
 *
 * TODO: maybe add retry logic in here, to help deal with potentially flaky APIs?
 */
public suspend fun  InputHandlerScope.fetchWithCache(
    input: Inputs,
    forceRefresh: Boolean,
    matchesPrerequisites: (State) -> Boolean = { true },
    getValue: (State) -> Cached,
    updateState: suspend (Cached) -> Inputs,
    doFetch: suspend (State) -> Unit,
    observe: Flow,
) {
    // VM is not ready to start fetching yet
    val currentState = getCurrentState()
    if (!matchesPrerequisites(currentState)) return

    // VM is already fetching when another request came in. If the second request is a forced refresh, cancel the first
    // by restarting the side-job. If the second request is not a forced refresh, return and allow the original to
    // continue executing.
    if (getValue(currentState) is Cached.Fetching && !forceRefresh) return

    sideJob(input::class.simpleName!!) {
        val initialValue = getValue(currentStateWhenStarted)
        val currentValueUnboxed = initialValue.getCachedOrNull()
        val currentValue = if (forceRefresh) {
            // if forcing a refresh, first mark it as not loaded (but keep the previous value for a better UI experience
            // when re-fetching)
            Cached.NotLoaded(currentValueUnboxed).also { postInput(updateState(it)) }
        } else {
            // otherwise, use the existing value as the current cached value
            initialValue
        }

        // if we have not loaded yet, have requested a forced refresh, or the previous attempt to fetch failed, try fetching
        // from the remote source now
        if (currentValue is Cached.NotLoaded || currentValue is Cached.FetchingFailed) {
            postInput(updateState(Cached.Fetching(currentValueUnboxed)))

            try {
                coroutineScope {
                    doFetch(currentStateWhenStarted)
                }
            } catch (t: Throwable) {
                postInput(updateState(Cached.FetchingFailed(t, currentValueUnboxed)))
            }
        }

        // start observing the remote source so that we can use it as we send along with the cached states
        observe
            .onEach { postInput(updateState(Cached.Value(it))) }
            .launchIn(this)
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy