diff --git a/core/pagination/.gitignore b/core/pagination/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core/pagination/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/pagination/build.gradle.kts b/core/pagination/build.gradle.kts new file mode 100644 index 0000000000..c7b5d3d97a --- /dev/null +++ b/core/pagination/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + // region Coroutines + implementation(deps.kotlin.coroutines) + // endregion +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/Batch.kt b/core/pagination/src/main/java/com/tangem/pagination/Batch.kt new file mode 100644 index 0000000000..b4b2482eaa --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/Batch.kt @@ -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( + val key: K, + val data: T, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt new file mode 100644 index 0000000000..710e191dd7 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt @@ -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 { + + /** + * Action to load the first batch. + * + * @param request request to load the first batch. + */ + data class Reload( + val request: R, + ) : BatchAction() + + /** + * 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( + val request: R? = null, + ) : BatchAction() + + /** + * Action to update the batch. + * + * @param keys keys of the batches to update. + * @param request request to update the batches. + */ + class UpdateBatches( + val keys: Set, + val request: U, + ) : BatchAction() + + /** + * Action to cancel the current batch loading. + */ + data object CancelBatchLoading : BatchAction() + + /** + * Action to cancel all update requests. + */ + data object CancelAllUpdates : BatchAction() + + /** + * Action to cancel update requests that satisfy the predicate. + * + * @param predicate predicate to check if the update request should be cancelled. + */ + class CancelUpdates( + val predicate: (UpdateBatches) -> Boolean, + ) : BatchAction() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt new file mode 100644 index 0000000000..d4ea1d4ae7 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt @@ -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 { + + /** + * 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( + val data: T, + val last: Boolean = false, + ) : BatchFetchResult() + + /** + * Represents an error result of a batch fetch request. + * + * @param error error that occurred during the request. + */ + data class Error(val error: E) : BatchFetchResult() + + /** + * 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() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchUpdateResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchUpdateResult.kt new file mode 100644 index 0000000000..7d47ed8142 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchUpdateResult.kt @@ -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 { + + /** + * Represents a successful result of a batch update operation. + * + * @param data fetched data. + */ + data class Success( + val data: List>, + ) : BatchFetchUpdateResult() + + /** + * Represents an error result of a batch update operation. + * + * @param error error that occurred during the operation. + */ + data class Error(val error: TError) : BatchFetchUpdateResult() + + /** + * 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() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetcher.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetcher.kt new file mode 100644 index 0000000000..aea9c1bd37 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetcher.kt @@ -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 { + + /** + * Fetches a batch of data. + * + * @param request request to fetch the data. + * @return result of the fetch operation. + */ + suspend fun fetch(request: BatchRequest): BatchFetchResult +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt new file mode 100644 index 0000000000..a3e529d029 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -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 { + val state: StateFlow> + val updateResults: SharedFlow>> +} + +/** + * 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 BatchListSource( + config: BatchingConfig, + context: BatchingContext, + generateNewKey: suspend (List) -> TKey, + batchFetcher: BatchFetcher, +): BatchListSource = + 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 BatchListSource( + config: BatchingConfig, + context: BatchingContext, + generateNewKey: suspend (List) -> TKey, + batchFetcher: BatchFetcher, + updateFetcher: BatchUpdateFetcher, +): BatchListSource = + BatchListSourceImpl(config, context, generateNewKey, batchFetcher, updateFetcher) + +private class BatchListSourceImpl( + private val config: BatchingConfig, + private val context: BatchingContext, + private val generateNewKey: suspend (List) -> TKey, + private val batchFetcher: BatchFetcher, + private val updateFetcher: BatchUpdateFetcher? = null, +) : BatchListSource { + + override val state = MutableStateFlow(BatchListState(emptyList(), PaginationStatus.None)) + override val updateResults = MutableSharedFlow>>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val scope = context.coroutineScope + private val updateJobs = MutableStateFlow, Job>>>(emptyList()) + private val waitingUpdateJobs = + MutableStateFlow, Job>>>(emptyList()) + + private val lastRequest = MutableStateFlow?>(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) { + 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) { + 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) { + val status = state.value.status + val lastReq = lastRequest.value + + val request: BatchRequest = 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) { + 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) -> 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 + } + } + } + } +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt new file mode 100644 index 0000000000..adce142dab --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt @@ -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( + val data: List>, + val status: PaginationStatus, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchRequest.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchRequest.kt new file mode 100644 index 0000000000..6710c62702 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchRequest.kt @@ -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( + val offset: Int, + val limit: Int, + val data: R, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateFetcher.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateFetcher.kt new file mode 100644 index 0000000000..71ddd43c76 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateFetcher.kt @@ -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 { + + /** + * 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>, + updateRequest: TUpdate, + ): BatchFetchUpdateResult +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchingConfig.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchingConfig.kt new file mode 100644 index 0000000000..8789d11405 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchingConfig.kt @@ -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, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchingContext.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchingContext.kt new file mode 100644 index 0000000000..95f10045c4 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchingContext.kt @@ -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( + val actionsFlow: Flow>, + val coroutineScope: CoroutineScope, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/PaginationStatus.kt b/core/pagination/src/main/java/com/tangem/pagination/PaginationStatus.kt new file mode 100644 index 0000000000..054178ce19 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/PaginationStatus.kt @@ -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 { + + /** + * Represents that there is no data. Used when the list of batches is empty. + * The initial state of the pagination. + */ + data object None : PaginationStatus() + + /** + * 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() + + /** + * Represents that the first batch was loaded with an error. + * + * @param error error that occurred during the initial loading. + */ + data class InitialLoadingError( + val error: E?, + ) : PaginationStatus() + + /** + * 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( + val lastResult: BatchFetchResult, + ) : PaginationStatus() + + /** + * Represents that the next batch is loading. + * Used when the next batch is being loaded. + */ + data object NextBatchLoading : PaginationStatus() + + /** + * 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() +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 3637dd3cc9..6d1f411939 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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