Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-03 14:09:16 +03:00
parent 679469c91f
commit 8737ebd291
15 changed files with 674 additions and 0 deletions

1
core/pagination/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,10 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
// region Coroutines
implementation(deps.kotlin.coroutines)
// endregion
}

View file

@ -0,0 +1,13 @@
package com.tangem.pagination
/**
* Represents a batch of data with a key.
* Used in [BatchListState].
*
* @param K type of the key.
* @param T type of the data.
*/
data class Batch<K, T>(
val key: K,
val data: T,
)

View file

@ -0,0 +1,61 @@
package com.tangem.pagination
/**
* Action that can be dispatched to [BatchListSource].
*
* @param R type of the request to load batches.
* @param K type of the key of the batch.
* @param U type of the update request.
*/
sealed class BatchAction<R, K, U> {
/**
* Action to load the first batch.
*
* @param request request to load the first batch.
*/
data class Reload<R>(
val request: R,
) : BatchAction<R, Nothing, Nothing>()
/**
* Action to load the next batch.
*
* @param request request to load the next batch with new request.
* If null, the last request will be used.
* Will be saved in the state and used for future LoadMore actions with request = null.
*/
data class LoadMore<R>(
val request: R? = null,
) : BatchAction<R, Nothing, Nothing>()
/**
* Action to update the batch.
*
* @param keys keys of the batches to update.
* @param request request to update the batches.
*/
class UpdateBatches<K, U>(
val keys: Set<K>,
val request: U,
) : BatchAction<Nothing, K, U>()
/**
* Action to cancel the current batch loading.
*/
data object CancelBatchLoading : BatchAction<Nothing, Nothing, Nothing>()
/**
* Action to cancel all update requests.
*/
data object CancelAllUpdates : BatchAction<Nothing, Nothing, Nothing>()
/**
* Action to cancel update requests that satisfy the predicate.
*
* @param predicate predicate to check if the update request should be cancelled.
*/
class CancelUpdates<K, U>(
val predicate: (UpdateBatches<K, U>) -> Boolean,
) : BatchAction<Nothing, K, U>()
}

View file

@ -0,0 +1,37 @@
package com.tangem.pagination
/**
* Represents a result of a batch fetch request.
* Used in [BatchListState].
*
* @param T type of the data.
* @param E type of the error.
*/
sealed class BatchFetchResult<out T, out E> {
/**
* Represents a successful result of a batch fetch request.
*
* @param data fetched data.
* @param last indicates if this is the last batch for the request.
*/
data class Success<T>(
val data: T,
val last: Boolean = false,
) : BatchFetchResult<T, Nothing>()
/**
* Represents an error result of a batch fetch request.
*
* @param error error that occurred during the request.
*/
data class Error<E>(val error: E) : BatchFetchResult<Nothing, E>()
/**
* Represents an unknown error result of a batch fetch request.
* Used for unexpected exceptions that occurred in fetch method in [BatchFetcher].
*
* @param throwable throwable that occurred during the request.
*/
class UnknownError(val throwable: Throwable) : BatchFetchResult<Nothing, Nothing>()
}

View file

@ -0,0 +1,36 @@
package com.tangem.pagination
/**
* Represents the result of a batch fetch operation.
* Used in [BatchListState] and [BatchUpdateFetcher].
*
* @param TKey type of the key.
* @param TData type of the data.
* @param TError type of the error.
*/
sealed class BatchFetchUpdateResult<out TKey, out TData, out TError> {
/**
* Represents a successful result of a batch update operation.
*
* @param data fetched data.
*/
data class Success<TKey, TData>(
val data: List<Batch<TKey, TData>>,
) : BatchFetchUpdateResult<TKey, TData, Nothing>()
/**
* Represents an error result of a batch update operation.
*
* @param error error that occurred during the operation.
*/
data class Error<TError>(val error: TError) : BatchFetchUpdateResult<Nothing, Nothing, TError>()
/**
* Represents an unknown error result of a batch update operation.
* Used for unexpected exceptions that occurred in `fetchUpdate` method in [BatchUpdateFetcher].
*
* @param throwable throwable that occurred during the operation.
*/
class UnknownError(val throwable: Throwable) : BatchFetchUpdateResult<Nothing, Nothing, Nothing>()
}

View file

@ -0,0 +1,21 @@
package com.tangem.pagination
/**
* Interface for fetching a batch of data. Used in [BatchListState].
*
* @param TRequest type of the request.
* @param TData type of the data.
* @param TError type of the error.
*
* @see BatchListState
*/
interface BatchFetcher<TRequest, TData, TError> {
/**
* Fetches a batch of data.
*
* @param request request to fetch the data.
* @return result of the fetch operation.
*/
suspend fun fetch(request: BatchRequest<TRequest>): BatchFetchResult<TData, TError>
}

View file

@ -0,0 +1,343 @@
package com.tangem.pagination
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
/**
* Source for paginated data. The starting point for pagination.
*
* @param TKey Type of the key that identifies a batch.
* @param TData Type of the data in the batch. Generally, it' a list of items.
* @param TUpdate Type of the update request.
* @param TError Type of the error that can occur during fetching or updating.
* @property state State of the paginated data.
* @property updateResults Flow of results of update requests.
*/
interface BatchListSource<TKey, TData, TUpdate, TError> {
val state: StateFlow<BatchListState<TKey, TData, TError>>
val updateResults: SharedFlow<Pair<TUpdate, BatchFetchUpdateResult<TKey, TData, TError>>>
}
/**
* Creates a new [BatchListSource] with the provided configuration.
*
* @param config Configuration for batching.
* @param context Context for batching.
* @param generateNewKey Function to generate a new key for a batch.
* @param batchFetcher Function to fetch a batch of data.
*
* @return New instance of [BatchListSource].
*/
@Suppress("FunctionNaming")
fun <TKey, TData, TRequest, TError> BatchListSource(
config: BatchingConfig,
context: BatchingContext<TRequest, TKey, Nothing>,
generateNewKey: suspend (List<TKey>) -> TKey,
batchFetcher: BatchFetcher<TRequest, TData, TError>,
): BatchListSource<TKey, TData, Nothing, TError> =
BatchListSourceImpl(config, context, generateNewKey, batchFetcher, null)
/**
* Creates a new [BatchListSource] with the provided configuration.
*
* @param config Configuration for batching.
* @param context Context for batching.
* @param generateNewKey Function to generate a new key for a batch.
* @param batchFetcher Function to fetch a batch of data.
* @param updateFetcher Function to fetch updates for batches.
*
* @return New instance of [BatchListSource].
*/
@Suppress("FunctionNaming")
fun <TKey, TData, TUpdate, TRequest, TError> BatchListSource(
config: BatchingConfig,
context: BatchingContext<TRequest, TKey, TUpdate>,
generateNewKey: suspend (List<TKey>) -> TKey,
batchFetcher: BatchFetcher<TRequest, TData, TError>,
updateFetcher: BatchUpdateFetcher<TKey, TData, TError, TUpdate>,
): BatchListSource<TKey, TData, TUpdate, TError> =
BatchListSourceImpl(config, context, generateNewKey, batchFetcher, updateFetcher)
private class BatchListSourceImpl<TKey, TData, TUpdate, TRequest, TError>(
private val config: BatchingConfig,
private val context: BatchingContext<TRequest, TKey, TUpdate>,
private val generateNewKey: suspend (List<TKey>) -> TKey,
private val batchFetcher: BatchFetcher<TRequest, TData, TError>,
private val updateFetcher: BatchUpdateFetcher<TKey, TData, TError, TUpdate>? = null,
) : BatchListSource<TKey, TData, TUpdate, TError> {
override val state = MutableStateFlow(BatchListState<TKey, TData, TError>(emptyList(), PaginationStatus.None))
override val updateResults = MutableSharedFlow<Pair<TUpdate, BatchFetchUpdateResult<TKey, TData, TError>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val scope = context.coroutineScope
private val updateJobs = MutableStateFlow<List<Pair<BatchAction.UpdateBatches<TKey, TUpdate>, Job>>>(emptyList())
private val waitingUpdateJobs =
MutableStateFlow<List<Pair<BatchAction.UpdateBatches<TKey, TUpdate>, Job>>>(emptyList())
private val lastRequest = MutableStateFlow<BatchRequest<TRequest>?>(null)
private var reloadActionJob: Job? = null
private var loadMoreActionJob: Job? = null
init {
scope.launch {
try {
awaitCancellation()
} finally {
withContext(NonCancellable) {
lastRequest.value = null
loadMoreActionJob = null
loadMoreActionJob = null
lastRequest.value = null
stopAllUpdates()
state.value = BatchListState(emptyList(), PaginationStatus.None)
}
}
}
scope.launch {
context.actionsFlow.collect { action ->
collectActions(action)
}
}
}
private fun collectActions(action: BatchAction<TRequest, TKey, TUpdate>) {
when (action) {
is BatchAction.Reload -> {
// Stop all tasks
loadMoreActionJob?.cancel()
reloadActionJob?.cancel()
stopAllUpdates()
reloadActionJob = scope.launch(Dispatchers.IO) {
reloadTask(action)
}
}
is BatchAction.LoadMore -> {
if (loadMoreActionJob?.isActive == true) {
return
}
loadMoreActionJob = scope.launch(Dispatchers.IO) {
reloadActionJob?.join()
loadMoreTask(action)
}
}
is BatchAction.UpdateBatches -> {
if (updateFetcher == null) return
scope.launch(Dispatchers.IO) {
val job = launch(start = CoroutineStart.LAZY) {
updateBatchesTask(action)
}
val actionJob = action to job
waitingUpdateJobs.update { it + actionJob }
updateJobs.first { workingJobs ->
action.keys.intersect(workingJobs.map { it.first.keys }.flatten().toSet()).isEmpty()
}
waitingUpdateJobs.update { it - actionJob }
val started = job.start()
if (started) {
updateJobs.update { it + actionJob }
job.invokeOnCompletion { cause ->
if (cause !is CancellationException) {
updateJobs.update { it - actionJob }
}
}
}
}
}
BatchAction.CancelAllUpdates -> {
if (updateFetcher == null) return
stopAllUpdates()
}
is BatchAction.CancelUpdates -> {
if (updateFetcher == null) return
stopUpdates(action.predicate)
}
BatchAction.CancelBatchLoading -> {
loadMoreActionJob?.cancel()
reloadActionJob?.cancel()
}
}
}
private suspend fun reloadTask(action: BatchAction.Reload<TRequest>) {
state.value = BatchListState(
data = emptyList(),
status = PaginationStatus.InitialLoading,
)
val requestData = action.request
val request = BatchRequest(offset = 0, limit = config.batchSize, requestData)
val res = runCatching {
batchFetcher.fetch(request)
}.getOrElse { BatchFetchResult.UnknownError(it) }
state.value = if (res is BatchFetchResult.Success) {
val key = generateNewKey(listOf())
val batch = Batch(
key = key,
data = res.data,
)
BatchListState(
data = listOf(batch),
status = if (res.last) {
PaginationStatus.EndOfPagination
} else {
PaginationStatus.Paginating(res)
},
)
} else {
BatchListState(
data = emptyList(),
status = PaginationStatus.InitialLoadingError(
error = (res as? BatchFetchResult.Error)?.error,
),
)
}
lastRequest.value = request
}
private suspend fun loadMoreTask(action: BatchAction.LoadMore<TRequest>) {
val status = state.value.status
val lastReq = lastRequest.value
val request: BatchRequest<TRequest> = when {
// try to continue pagination with new request
status is PaginationStatus.EndOfPagination && action.request != null && action.request != lastReq -> {
requireNotNull(lastReq)
BatchRequest(
offset = lastReq.offset + lastReq.limit,
limit = config.batchSize,
data = action.request,
)
}
// continue pagination
status is PaginationStatus.Paginating -> {
requireNotNull(lastReq)
if (status.lastResult is BatchFetchResult.Success) {
BatchRequest(
offset = lastReq.offset + lastReq.limit,
limit = config.batchSize,
data = action.request ?: lastReq.data,
)
} else {
BatchRequest(
offset = lastReq.offset,
limit = lastReq.limit,
data = action.request ?: lastReq.data,
)
}
}
else -> return
}
state.update { it.copy(status = PaginationStatus.NextBatchLoading) }
val res = runCatching {
batchFetcher.fetch(request)
}.getOrElse { BatchFetchResult.UnknownError(it) }
lastRequest.value = request
state.update { currentState ->
if (res is BatchFetchResult.Success) {
val newBatch = Batch(
key = generateNewKey(currentState.data.map { it.key }),
data = res.data,
)
currentState.copy(
data = currentState.data + newBatch,
status = if (res.last) {
PaginationStatus.EndOfPagination
} else {
PaginationStatus.Paginating(res)
},
)
} else {
currentState.copy(
status = PaginationStatus.Paginating(res),
)
}
}
}
private suspend fun updateBatchesTask(action: BatchAction.UpdateBatches<TKey, TUpdate>) {
if (updateFetcher == null) return
val batches = state.value.data
val batchesToUpdate = batches.filter { action.keys.contains(it.key) }
val result = updateFetcher.fetchUpdate(
toUpdate = batchesToUpdate,
updateRequest = action.request,
)
if (result is BatchFetchUpdateResult.Success) {
state.update { currentState ->
val resMap = result.data.associateBy { it.key }
currentState.copy(
data = currentState.data.map {
resMap[it.key] ?: it
},
)
}
}
updateResults.emit(action.request to result)
}
private fun stopAllUpdates() {
updateJobs.update { actionJobs ->
waitingUpdateJobs.update { waitingActionJobs ->
waitingActionJobs.forEach {
it.second.cancel()
}
emptyList()
}
actionJobs.forEach {
it.second.cancel()
}
emptyList()
}
}
private fun stopUpdates(predicate: (BatchAction.UpdateBatches<TKey, TUpdate>) -> Boolean) {
updateJobs.update { actionJobs ->
waitingUpdateJobs.update { waitingActionJobs ->
waitingActionJobs.mapNotNull {
if (predicate(it.first)) {
it.second.cancel()
null
} else {
it
}
}
}
actionJobs.mapNotNull {
if (predicate(it.first)) {
it.second.cancel()
null
} else {
it
}
}
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.pagination
/**
* State that is used for listening the current state of a pagination.
*
* @param K type of the key of the batch.
* @param T type of the data.
* @param E type of the error.
*
* @property data list of loaded batches.
* @property status current status of the pagination.
*
* @see BatchListSource
*/
data class BatchListState<K, T, E>(
val data: List<Batch<K, T>>,
val status: PaginationStatus<T, E>,
)

View file

@ -0,0 +1,18 @@
package com.tangem.pagination
/**
* Request to fetch a batch of data.
*
* @param R type of the request.
*
* @property offset offset for the request.
* @property limit limit for the request.
* @property data body of the request.
*
* @see BatchFetcher
*/
data class BatchRequest<R>(
val offset: Int,
val limit: Int,
val data: R,
)

View file

@ -0,0 +1,25 @@
package com.tangem.pagination
/**
* Interface for fetching updates for a batch of data.
* Used in [BatchListState].
*
* @param TKey type of the key.
* @param TData type of the data.
* @param TError type of the error.
* @param TUpdate type of the update request.
*/
interface BatchUpdateFetcher<TKey, TData, TError, TUpdate> {
/**
* Fetches updates for a batch of data.
*
* @param toUpdate list of batches to update.
* @param updateRequest request to update the data.
* @return result of the update operation.
*/
suspend fun fetchUpdate(
toUpdate: List<Batch<TKey, TData>>,
updateRequest: TUpdate,
): BatchFetchUpdateResult<TKey, TData, TError>
}

View file

@ -0,0 +1,12 @@
package com.tangem.pagination
/**
* Configuration for batching.
*
* @param batchSize size of the batch.
* @param prefetchDistance number of items to fetch for the first batch.
*/
data class BatchingConfig(
val batchSize: Int,
val prefetchDistance: Int = batchSize,
)

View file

@ -0,0 +1,22 @@
package com.tangem.pagination
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
/**
* Context for working with [BatchListSource].
*
* @param R type of the request.
* @param K type of the key.
* @param U type of the update request.
*
* @property actionsFlow flow of [BatchAction]s that would be dispatched to [BatchListSource].
* @property coroutineScope scope for the [BatchListSource] to launch coroutines. When it is cancelled,
* all the operations and requests launched in the [BatchListSource] would be cancelled and all data would be cleared.
*
* @see BatchListSource
*/
class BatchingContext<R, K, U>(
val actionsFlow: Flow<BatchAction<R, K, U>>,
val coroutineScope: CoroutineScope,
)

View file

@ -0,0 +1,56 @@
package com.tangem.pagination
/**
* Status of the pagination.
*
* @param T type of the data.
* @param E type of the error.
*
* @see BatchListState
*/
sealed class PaginationStatus<out T, out E> {
/**
* Represents that there is no data. Used when the list of batches is empty.
* The initial state of the pagination.
*/
data object None : PaginationStatus<Nothing, Nothing>()
/**
* Represents that the batch is loading for the first time.
* Used when the pagination is empty and the first batch is being loaded.
*/
data object InitialLoading : PaginationStatus<Nothing, Nothing>()
/**
* Represents that the first batch was loaded with an error.
*
* @param error error that occurred during the initial loading.
*/
data class InitialLoadingError<T, E>(
val error: E?,
) : PaginationStatus<T, E>()
/**
* Represents that the last batch was loaded and
* the source is ready to load the next one or reload previous if [lastResult] is an error.
* For the first batch, [lastResult] is always [BatchFetchResult.Success]
*
* @param lastResult result of the last batch fetch.
*/
data class Paginating<out T, out E>(
val lastResult: BatchFetchResult<T, E>,
) : PaginationStatus<T, E>()
/**
* Represents that the next batch is loading.
* Used when the next batch is being loaded.
*/
data object NextBatchLoading : PaginationStatus<Nothing, Nothing>()
/**
* Represents that the source has no more batches to load.
* The next [BatchAction.LoadMore] with [BatchAction.LoadMore.request] = null will be ignored.
*/
data object EndOfPagination : PaginationStatus<Nothing, Nothing>()
}

View file

@ -127,6 +127,7 @@ include(":core:utils")
include(":core:deep-links")
include(":core:deep-links:global")
include(":core:decompose")
include(":core:pagination")
// endregion Core modules
// region Common modules