diff --git a/.gitignore b/.gitignore index b54ac9caf..f61cac31f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ captures/ store/kover *.podspec -yarn.lock \ No newline at end of file +yarn.lock +*/kover \ No newline at end of file diff --git a/paging/README.md b/paging/README.md new file mode 100644 index 000000000..4dd13549e --- /dev/null +++ b/paging/README.md @@ -0,0 +1,315 @@ +# Paging + +[![codecov](https://codecov.io/gh/matt-ramotar/Paging/graph/badge.svg?token=62YL5HZR9Q)](https://codecov.io/gh/matt-ramotar/Paging) + +A solution for efficient paging in Kotlin Multiplatform projects. + +## Features + +- Prioritizes extensibility with support for custom middleware, reducers, post-reducer effects, paging strategies, and data sources +- Supports delegating to [Store](https://github.com/MobileNativeFoundation/Store) for optimized data loading and caching +- Opens up mutations and streaming of child items within the list of paging items +- Includes built-in hooks for logging and error handling +- Uses a modular architecture with unidirectional data flow to make it easier to reason about and maintain + +## Installation + +Add the following dependency to your project: + +```kotlin +dependencies { + implementation("org.mobilenativefoundation.paging:core:1.0.0-SNAPSHOT") +} +``` + +## Getting Started + +### 1. Create a `PagingConfig` to configure the paging behavior: + +```kotlin +val pagingConfig = PagingConfig( + pageSize = 20, + prefetchDistance = 10, + insertionStrategy = InsertionStrategy.APPEND +) +``` + +### 2. Implement a `PagingSource` to provide data for pagination: + +```kotlin +val pagingSource = DefaultPagingSource( + streamProvider = store.pagingSourceStreamProvider(keyFactory) +) +``` + +### 3. Configure the `Pager` using `PagerBuilder`: + +```kotlin +val pager = PagerBuilder( + initialKey = PagingKey(key = 1, params = MyParams()), + anchorPosition = anchorPositionFlow, +) + .pagingConfig(pagingConfig) + + .pagerBufferMaxSize(100) + + // Provide a custom paging source + .pagingSource(MyCustomPagingSource()) + + // Or, use the default paging source + .defaultPagingSource(MyPagingSourceStreamProvider()) + + // Or, use Store as your paging source + .mutableStorePagingSource(mutableStore) + + // Use the default reducer + .defaultReducer { + errorHandlingStrategy(ErrorHandlingStrategy.RetryLast(3)) + customActionReducer(MyCustomActionReducer()) + } + + // Or, provide a custom reducer + .reducer(MyCustomReducer()) + + // Add custom middleware + .middleware(MyCustomMiddleware1()) + .middleware(MyCustomMiddleware2()) + + // Add custom post-reducer effects + .effect( + action = SomePagingAction::class, + state = SomePagingState::class, + effect = MyCustomEffect1() + ) + + .effect( + action = SomePagingAction::class, + state = SomePagingState::class, + effect = MyCustomEffect2() + ) + + // Use the default logger + .defaultLogger() + + .build() +``` + +### 4. Observe the paging state and dispatch actions: + +```kotlin +pager.state.collect { state -> + when (state) { + is PagingState.Loading -> { + // Show loading indicator + InitialLoadingView() + } + + is PagingState.Data.Idle -> { + // Update UI with loaded data + DataView(pagingItems = state.data) { action -> + pager.dispatch(action) + } + } + + is PagingState.Error -> { + // Handle error state + ErrorViewCoordinator(errorState = state) { action -> + pager.dispatch(action) + } + } + } +} +``` + +## Advanced Usage + +### Using Type Aliases + +```kotlin +typealias Id = MyId +typealias K = MyKey +typealias P = MyParams +typealias D = MyData +typealias E = MyCustomError +typealias A = MyCustomAction +``` + +### Handling Errors + +This library supports different error handling strategies to handle errors that occur during the paging process. + +#### 1. **Built-In Error Handling**: You can configure error handling strategy using the `errorHandlingStrategy` function when building the pager. + +```kotlin +val pager = PagerBuilder( + scope, + initialKey, + initialState, + anchorPosition +) + .defaultReducer { + // Retry without emitting the error + errorHandlingStrategy(ErrorHandlingStrategy.RetryLast(3)) + + // Emit the error + errorHandlingStrategy(ErrorHandlingStrategy.PassThrough) + + // Ignore the error + errorHandlingStrategy(ErrorHandlingStrategy.Ignore) + } +``` + +#### 2. **Custom Middleware**: You can add custom middleware for handling errors. + +```kotlin +sealed class CustomError { + data class Enriched( + val throwable: Throwable, + val context: CustomContext + ) : CustomError() +} + +class ErrorEnrichingMiddleware( + private val contextProvider: CustomContextProvider +) : Middleware { + override suspend fun apply( + action: PagingAction, + next: suspend (PagingAction) -> Unit + ) { + if (action is PagingAction.UpdateError) { + val modifiedError = CustomError.Enriched(action.error, contextProvider.requireContext()) + next(action.copy(error = modifiedError)) + } else { + next(action) + } + } +} + +val pager = PagerBuilder( + scope, + initialKey, + initialState, + anchorPosition +) + .middleware(ErrorEnrichingMiddleware(contextProvider)) +``` + +#### 3. **Custom Effects**: You can add custom post-reducer effects for handling errors. + +```kotlin +class ErrorLoggingEffect(private val logger: Logger) : + Effect, PagingState.Error.Exception> { + override fun invoke( + action: PagingAction.UpdateError, + state: PagingState.Error.Exception, + dispatch: (PagingAction) -> Unit + ) { + when (val error = action.error) { + is PagingSource.LoadResult.Error.Custom -> {} + is PagingSource.LoadResult.Error.Exception -> { + logger.log(error) + } + } + } +} + +val pager = PagerBuilder( + scope, + initialKey, + initialState, + anchorPosition +) + .effect(PagingAction.UpdateError::class, PagingState.Error.Exception::class, errorLoggingEffect) +``` + +### Reducing Custom Actions + +```kotlin +sealed interface MyCustomAction { + data object ClearData : TimelineAction +} + +class MyCustomActionReducer : UserCustomActionReducer { + override fun reduce(action: PagingAction.User.Custom, state: PagingState): PagingState { + return when (action.action) { + MyCustomAction.ClearData -> { + when (state) { + is PagingState.Data.ErrorLoadingMore -> state.copy(data = emptyList()) + is PagingState.Data.Idle -> state.copy(data = emptyList()) + is PagingState.Data.LoadingMore -> state.copy(data = emptyList()) + is PagingState.Error.Custom, + is PagingState.Error.Exception, + is PagingState.Initial, + is PagingState.Loading -> state + } + } + } + } +} + +val pager = PagerBuilder( + scope, + initialKey, + initialState, + anchorPosition +) + .defaultReducer { + customActionReducer(MyCustomActionReducer()) + } +``` + +### Intercepting and Modifying Actions + +```kotlin +class AuthMiddleware(private val authTokenProvider: () -> String) : Middleware { + private fun setAuthToken(headers: MutableMap) = headers.apply { + this["auth"] = authTokenProvider() + } + + override suspend fun apply(action: PagingAction, next: suspend (PagingAction) -> Unit) { + when (action) { + is PagingAction.User.Load -> { + setAuthToken(action.key.params.headers) + next(action) + } + + is PagingAction.Load -> { + setAuthToken(action.key.params.headers) + next(action) + } + + else -> next(action) + } + } +} + +val pager = PagerBuilder( + scope, + initialKey, + initialState, + anchorPosition +) + .middleware(AuthMiddleware(authTokenProvider)) +``` + +### Performing Side Effects After State Has Been Reduced + +See the [Custom Effects](#3-custom-effects-you-can-add-custom-post-reducer-effects-for-handling-errors) section under [Handling Errors](#handling-errors). + +## License + +``` +Copyright 2024 Mobile Native Foundation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/paging/build.gradle.kts b/paging/core/build.gradle.kts similarity index 52% rename from paging/build.gradle.kts rename to paging/core/build.gradle.kts index f93e1b172..6fc4d4610 100644 --- a/paging/build.gradle.kts +++ b/paging/core/build.gradle.kts @@ -1,21 +1,14 @@ -import com.vanniktech.maven.publish.SonatypeHost import org.jetbrains.dokka.gradle.DokkaTask plugins { kotlin("multiplatform") - kotlin("plugin.serialization") id("com.android.library") - id("com.vanniktech.maven.publish") id("org.jetbrains.dokka") id("org.jetbrains.kotlinx.kover") - id("co.touchlab.faktory.kmmbridge") - `maven-publish` - kotlin("native.cocoapods") - id("kotlinx-atomicfu") } kotlin { - android() + androidTarget() jvm() iosArm64() iosX64() @@ -25,21 +18,14 @@ kotlin { browser() nodejs() } - cocoapods { - summary = "Store5/Paging" - homepage = "https://github.com/MobileNativeFoundation/Store" - ios.deploymentTarget = "13" - version = libs.versions.store.get() - } sourceSets { val commonMain by getting { dependencies { implementation(libs.kotlin.stdlib) - implementation(project(":store")) - implementation(project(":cache")) - api(project(":core")) + implementation(libs.touchlab.kermit) implementation(libs.kotlinx.coroutines.core) + api(project(":store")) } } @@ -49,6 +35,7 @@ kotlin { implementation(kotlin("test")) implementation(libs.turbine) implementation(libs.kotlinx.coroutines.test) + implementation(project(":store")) } } } @@ -57,27 +44,11 @@ kotlin { } android { - namespace = "org.mobilenativefoundation.store.paging5" + namespace = "org.mobilenativefoundation.paging.core" sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - compileSdk = 33 - - defaultConfig { - minSdk = 24 - targetSdk = 33 - } - - lint { - disable += "ComposableModifierFactory" - disable += "ModifierFactoryExtensionFunction" - disable += "ModifierFactoryReturnType" - disable += "ModifierFactoryUnreferencedReceiver" - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } + compileSdk = libs.versions.androidCompileSdk.get().toInt() } tasks.withType().configureEach { @@ -88,19 +59,6 @@ tasks.withType().configureEach { } } -mavenPublishing { - publishToMavenCentral(SonatypeHost.S01) - signAllPublications() -} - -addGithubPackagesRepository() -kmmbridge { - githubReleaseArtifacts() - githubReleaseVersions() - versionPrefix.set(libs.versions.store.get()) - spm() -} - koverMerged { enable() @@ -117,9 +75,4 @@ koverMerged { verify { onCheck.set(true) } -} - -atomicfu { - transformJvm = false - transformJs = false -} +} \ No newline at end of file diff --git a/paging/core/gradle.properties b/paging/core/gradle.properties new file mode 100644 index 000000000..db6357895 --- /dev/null +++ b/paging/core/gradle.properties @@ -0,0 +1,3 @@ +POM_NAME=org.mobilenativefoundation.paging +POM_ARTIFACT_ID=core +POM_PACKAGING=jar \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/AggregatingStrategy.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/AggregatingStrategy.kt new file mode 100644 index 000000000..d835a782c --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/AggregatingStrategy.kt @@ -0,0 +1,31 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a strategy for aggregating loaded pages of data into a single instance of [PagingItems]. + * + * The [AggregatingStrategy] determines how the loaded pages of data should be combined and ordered to form a coherent list of [PagingData.Single] items. + * It takes into account the anchor position, prefetch position, paging configuration, and the current state of the paging buffer. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + */ +interface AggregatingStrategy, K : Any, P : Any, D : Any> { + + /** + * Aggregates the loaded pages of data into a single instance of [PagingItems]. + * + * @param anchorPosition The current anchor position in the paged data. + * @param prefetchPosition The position to prefetch data from, or `null` if no prefetching is needed. + * @param pagingConfig The configuration of the pager, including page size and prefetch distance. + * @param pagingBuffer The current state of the paging buffer, containing the loaded data. + * @return The aggregated list of [PagingItems] representing the combined and ordered paging data. + */ + fun aggregate( + anchorPosition: PagingKey, + prefetchPosition: PagingKey?, + pagingConfig: PagingConfig, + pagingBuffer: PagingBuffer, + ): PagingItems +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/DefaultReducerBuilder.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/DefaultReducerBuilder.kt new file mode 100644 index 000000000..4c39fc167 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/DefaultReducerBuilder.kt @@ -0,0 +1,103 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.paging.core.impl.DefaultAggregatingStrategy +import org.mobilenativefoundation.paging.core.impl.DefaultFetchingStrategy +import org.mobilenativefoundation.paging.core.impl.DefaultReducer +import org.mobilenativefoundation.paging.core.impl.Dispatcher +import org.mobilenativefoundation.paging.core.impl.Injector +import org.mobilenativefoundation.paging.core.impl.JobCoordinator +import org.mobilenativefoundation.paging.core.impl.OptionalInjector +import org.mobilenativefoundation.paging.core.impl.RetriesManager + +/** + * A builder class for creating a default [Reducer] instance. + * + * It enables configuring error handling strategy, aggregating strategy, fetching strategy, custom action reducer, and paging buffer size. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + * @param initialKey The initial [PagingKey] used as the starting point for paging. + * @param childScope The [CoroutineScope] in which the reducer will operate. + * @param dispatcherInjector The [Injector] used to provide the [Dispatcher] instance. + * @param loggerInjector The [OptionalInjector] used to provide the optional [Logger] instance. + * @param pagingConfigInjector The [Injector] used to provide the [PagingConfig] instance. + * @param anchorPosition The [StateFlow] representing the anchor position for paging. + */ +class DefaultReducerBuilder, K : Any, P : Any, D : Any, E : Any, A : Any> internal constructor( + private val initialKey: PagingKey, + private val childScope: CoroutineScope, + private val dispatcherInjector: Injector>, + private val loggerInjector: OptionalInjector, + private val pagingConfigInjector: Injector, + private val anchorPosition: StateFlow>, + private val mutablePagingBufferInjector: Injector>, + private val jobCoordinator: JobCoordinator +) { + + private var errorHandlingStrategy: ErrorHandlingStrategy = ErrorHandlingStrategy.RetryLast() + private var aggregatingStrategy: AggregatingStrategy = DefaultAggregatingStrategy() + private var fetchingStrategy: FetchingStrategy = DefaultFetchingStrategy() + private var customActionReducer: UserCustomActionReducer? = null + + /** + * Sets the [ErrorHandlingStrategy] to be used by the reducer. + * + * @param errorHandlingStrategy The [ErrorHandlingStrategy] to be used. + * @return The [DefaultReducerBuilder] instance for chaining. + */ + fun errorHandlingStrategy(errorHandlingStrategy: ErrorHandlingStrategy) = apply { this.errorHandlingStrategy = errorHandlingStrategy } + + /** + * Sets the [AggregatingStrategy] to be used by the reducer. + * + * @param aggregatingStrategy The [AggregatingStrategy] to be used. + * @return The [DefaultReducerBuilder] instance for chaining. + */ + fun aggregatingStrategy(aggregatingStrategy: AggregatingStrategy) = apply { this.aggregatingStrategy = aggregatingStrategy } + + /** + * Sets the [FetchingStrategy] to be used by the reducer. + * + * @param fetchingStrategy The [FetchingStrategy] to be used. + * @return The [DefaultReducerBuilder] instance for chaining. + */ + fun fetchingStrategy(fetchingStrategy: FetchingStrategy) = apply { this.fetchingStrategy = fetchingStrategy } + + /** + * Sets the custom action reducer to be used by the reducer. + * + * @param customActionReducer The [UserCustomActionReducer] to be used. + * @return The [DefaultReducerBuilder] instance for chaining. + */ + fun customActionReducer(customActionReducer: UserCustomActionReducer) = apply { this.customActionReducer = customActionReducer } + + /** + * Builds and returns the configured default [Reducer] instance. + * + * @return The built default [Reducer] instance. + */ + fun build(): Reducer { + val mutablePagingBuffer = mutablePagingBufferInjector.inject() + + return DefaultReducer( + childScope = childScope, + dispatcherInjector = dispatcherInjector, + pagingConfigInjector = pagingConfigInjector, + userCustomActionReducer = customActionReducer, + anchorPosition = anchorPosition, + loggerInjector = loggerInjector, + mutablePagingBuffer = mutablePagingBuffer, + aggregatingStrategy = aggregatingStrategy, + initialKey = initialKey, + retriesManager = RetriesManager(), + errorHandlingStrategy = errorHandlingStrategy, + jobCoordinator = jobCoordinator + ) + } +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Effect.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Effect.kt new file mode 100644 index 000000000..da2db0ed0 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Effect.kt @@ -0,0 +1,20 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents an effect that can be triggered after reducing a specific [PagingAction] and [PagingState] combination. + * + * Effects are side effects or additional actions that need to be performed after the state has been reduced based on a dispatched action. + * They can be used for tasks such as loading more data, updating the UI, triggering network requests, or any other side effects that depend on the current state and action. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + * @param PA The specific type of [PagingAction] that triggers this effect. + * @param S The specific type of [PagingState] that triggers this effect. + */ +interface Effect, K : Any, P : Any, D : Any, E : Any, A : Any, PA : PagingAction, S : PagingState> { + operator fun invoke(action: PA, state: S, dispatch: (PagingAction) -> Unit) +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/ErrorHandlingStrategy.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/ErrorHandlingStrategy.kt new file mode 100644 index 000000000..906ccb124 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/ErrorHandlingStrategy.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents different strategies for handling errors during the paging process. + */ +sealed interface ErrorHandlingStrategy { + /** + * Ignores errors and continues with the previous state. + */ + data object Ignore : ErrorHandlingStrategy + + /** + * Passes the error to the UI layer for handling. + */ + data object PassThrough : ErrorHandlingStrategy + + /** + * Retries the last failed load operation. + * + * @property maxRetries The maximum number of retries before passing the error to the UI. Default is 3. + */ + data class RetryLast( + val maxRetries: Int = 3 + ) : ErrorHandlingStrategy +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/FetchingStrategy.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/FetchingStrategy.kt new file mode 100644 index 000000000..54d7fe705 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/FetchingStrategy.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a strategy for determining whether to fetch more data based on the current state of the pager. + * The fetching strategy is responsible for deciding whether to fetch more data based on the anchor position, + * prefetch position, paging configuration, and the current state of the paging buffer. + * + * Implementing a custom [FetchingStrategy] allows you to define your own logic for when to fetch more data. + * For example, you can fetch more data when the user scrolls near the end of the currently loaded data, or when a certain number of items are remaining in the buffer. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + */ +interface FetchingStrategy, K : Any, P : Any, D : Any> { + + /** + * Determines whether to fetch more data based on the current state of the pager. + * The [shouldFetch] implementation should determine whether more data should be fetched based on the provided parameters. + * + * @param anchorPosition The current anchor position in the paged data. + * @param prefetchPosition The position to prefetch data from, or `null` if no prefetching is needed. + * @param pagingConfig The configuration of the pager, including page size and prefetch distance. + * @param pagingBuffer The current state of the paging buffer, containing the loaded data. + * @return `true` if more data should be fetched, `false` otherwise. + */ + fun shouldFetch( + anchorPosition: PagingKey, + prefetchPosition: PagingKey?, + pagingConfig: PagingConfig, + pagingBuffer: PagingBuffer, + ): Boolean +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Logger.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Logger.kt new file mode 100644 index 000000000..3c865eb6c --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Logger.kt @@ -0,0 +1,5 @@ +package org.mobilenativefoundation.paging.core + +interface Logger { + fun log(message: String) +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Middleware.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Middleware.kt new file mode 100644 index 000000000..bd7299432 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Middleware.kt @@ -0,0 +1,29 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a middleware that intercepts and modifies paging actions before they reach the reducer. + * + * [Middleware] allows for pre-processing, logging, or any other custom logic to be applied to actions before they are handled by the [Reducer]. + * It can also modify or replace the action before passing it to the next [Middleware] or [Reducer]. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +interface Middleware, K : Any, P : Any, D : Any, E : Any, A : Any> { + + /** + * Applies the middleware logic to the given [action]. + * + * The middleware can perform any necessary pre-processing, logging, or modification of the action + * before invoking the [next] function to pass the action to the next middleware or the reducer. + * + * @param action The paging action to be processed by the middleware. + * @param next A suspending function that should be invoked with the processed action to pass it to the next middleware or the reducer. + * If the middleware does not want to pass the action further, it can choose not to invoke this function. + */ + suspend fun apply(action: PagingAction, next: suspend (PagingAction) -> Unit) +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/MutablePagingBuffer.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/MutablePagingBuffer.kt new file mode 100644 index 000000000..c79da554c --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/MutablePagingBuffer.kt @@ -0,0 +1,19 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a mutable version of [PagingBuffer] that allows adding and updating paging data. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + */ +interface MutablePagingBuffer, K : Any, P : Any, D : Any> : PagingBuffer { + /** + * Puts the loaded page of data associated with the specified [PagingSource.LoadParams] into the buffer. + * + * @param params The [PagingSource.LoadParams] associated with the loaded page. + * @param page The [PagingSource.LoadResult.Data] representing the loaded page of data. + */ + fun put(params: PagingSource.LoadParams, page: PagingSource.LoadResult.Data) +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Pager.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Pager.kt new file mode 100644 index 000000000..0fdc85af8 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Pager.kt @@ -0,0 +1,35 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.flow.StateFlow + +/** + * [Pager] is responsible for coordinating the paging process and providing access to the paging state and data. + * This is the main entry point for the [org.mobilenativefoundation.paging] library. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +interface Pager, K : Any, P : Any, D : Any, E : Any, A : Any> { + /** + * The current paging state exposed as a [StateFlow]. + * The paging state represents the current state of the paging data, including loaded pages, errors, and loading status. + * Observers can collect this flow to react to changes in the paging state. + */ + val state: StateFlow> + + /** + * Dispatches a user-initiated [PagingAction] to modify the paging state. + * + * User actions can be dispatched to trigger specific behaviors or modifications to the paging state. + * The dispatched action will go through the configured [Middleware] chain and [Reducer] before updating the paging state. + * After updating the state, the dispatched action will launch each configured post-reducer [Effect]. + * + * @param action The user-initiated [PagingAction] to dispatch. + */ + fun dispatch(action: PagingAction.User) +} + diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagerBuilder.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagerBuilder.kt new file mode 100644 index 000000000..94159f464 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagerBuilder.kt @@ -0,0 +1,311 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.plus +import org.mobilenativefoundation.paging.core.PagingConfig.InsertionStrategy +import org.mobilenativefoundation.paging.core.impl.DefaultAppLoadEffect +import org.mobilenativefoundation.paging.core.impl.DefaultFetchingStrategy +import org.mobilenativefoundation.paging.core.impl.DefaultLoadNextEffect +import org.mobilenativefoundation.paging.core.impl.DefaultLogger +import org.mobilenativefoundation.paging.core.impl.DefaultPagingSource +import org.mobilenativefoundation.paging.core.impl.DefaultPagingSourceCollector +import org.mobilenativefoundation.paging.core.impl.DefaultUserLoadEffect +import org.mobilenativefoundation.paging.core.impl.DefaultUserLoadMoreEffect +import org.mobilenativefoundation.paging.core.impl.Dispatcher +import org.mobilenativefoundation.paging.core.impl.EffectsHolder +import org.mobilenativefoundation.paging.core.impl.EffectsLauncher +import org.mobilenativefoundation.paging.core.impl.QueueManager +import org.mobilenativefoundation.paging.core.impl.RealDispatcher +import org.mobilenativefoundation.paging.core.impl.RealInjector +import org.mobilenativefoundation.paging.core.impl.RealJobCoordinator +import org.mobilenativefoundation.paging.core.impl.RealMutablePagingBuffer +import org.mobilenativefoundation.paging.core.impl.RealOptionalInjector +import org.mobilenativefoundation.paging.core.impl.RealPager +import org.mobilenativefoundation.paging.core.impl.RealQueueManager +import org.mobilenativefoundation.paging.core.impl.StateManager +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import kotlin.reflect.KClass + + +/** + * A builder class for creating a [Pager] instance. + * The [PagerBuilder] enables configuring the paging behavior, + * such as the initial state, initial key, anchor position, middleware, effects, reducer, logger, and paging config. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched. + * @param scope The [CoroutineScope] in which the paging operations will be performed. + * @param initialState The initial [PagingState] of the pager. + * @param initialKey The initial [PagingKey] of the pager. + * @param anchorPosition A [StateFlow] representing the anchor position for paging. + */ +class PagerBuilder, K : Any, P : Any, D : Any, E : Any, A : Any>( + scope: CoroutineScope, + initialState: PagingState, + private val initialKey: PagingKey, + private val anchorPosition: StateFlow> +) { + + private val childScope = scope + Job() + private val jobCoordinator = RealJobCoordinator(childScope) + + private var middleware: MutableList> = mutableListOf() + + private var pagingConfigInjector = RealInjector().apply { + instance = PagingConfig(10, 50, InsertionStrategy.APPEND) + } + + private var fetchingStrategyInjector = RealInjector>().apply { + this.instance = DefaultFetchingStrategy() + } + + private var pagingBufferMaxSize = 100 + + private val effectsHolder: EffectsHolder = EffectsHolder() + + private val dispatcherInjector = RealInjector>() + + private val loggerInjector = RealOptionalInjector() + + private val queueManagerInjector = RealInjector>() + private val mutablePagingBufferInjector = RealInjector>().apply { + this.instance = mutablePagingBufferOf(500) + } + + private val insertionStrategyInjector = RealInjector() + private val pagingSourceCollectorInjector = RealInjector>().apply { + this.instance = DefaultPagingSourceCollector() + } + private val pagingSourceInjector = RealInjector>() + + private val stateManager = StateManager(initialState, loggerInjector) + + private var loadNextEffect: LoadNextEffect = DefaultLoadNextEffect(loggerInjector, queueManagerInjector) + + private var appLoadEffect: AppLoadEffect = DefaultAppLoadEffect( + loggerInjector = loggerInjector, + dispatcherInjector = dispatcherInjector, + jobCoordinator = jobCoordinator, + pagingSourceCollectorInjector = pagingSourceCollectorInjector, + pagingSourceInjector = pagingSourceInjector, + stateManager = stateManager + ) + + private var userLoadEffect: UserLoadEffect = DefaultUserLoadEffect( + loggerInjector = loggerInjector, + dispatcherInjector = dispatcherInjector, + jobCoordinator = jobCoordinator, + pagingSourceCollectorInjector = pagingSourceCollectorInjector, + pagingSourceInjector = pagingSourceInjector, + stateManager = stateManager + ) + + private var userLoadMoreEffect: UserLoadMoreEffect = DefaultUserLoadMoreEffect( + loggerInjector = loggerInjector, + dispatcherInjector = dispatcherInjector, + jobCoordinator = jobCoordinator, + pagingSourceCollectorInjector = pagingSourceCollectorInjector, + pagingSourceInjector = pagingSourceInjector, + stateManager = stateManager + ) + + private lateinit var reducer: Reducer + + /** + * Sets the [Reducer] for the pager. + * + * @param reducer The [Reducer] to be used for reducing paging actions and state. + * @return The [PagerBuilder] instance for chaining. + */ + fun reducer(reducer: Reducer) = apply { this.reducer = reducer } + + /** + * Configures the default [Reducer] using the provided [DefaultReducerBuilder]. + * + * @param block A lambda function that takes a [DefaultReducerBuilder] as receiver and allows configuring the default reducer. + * @return The [PagerBuilder] instance for chaining. + */ + fun defaultReducer( + block: DefaultReducerBuilder.() -> Unit + ) = apply { + val builder = DefaultReducerBuilder( + childScope = childScope, + initialKey = initialKey, + dispatcherInjector = dispatcherInjector, + loggerInjector = loggerInjector, + pagingConfigInjector = pagingConfigInjector, + anchorPosition = anchorPosition, + mutablePagingBufferInjector = mutablePagingBufferInjector, + jobCoordinator = jobCoordinator + ) + block(builder) + val reducer = builder.build() + this.reducer = reducer + } + + /** + * Adds an [Effect] to be invoked after reducing the state for the specified [PagingAction] and [PagingState] types. + * + * @param PA The type of the [PagingAction] that triggers the effect. + * @param S The type of the [PagingState] that triggers the effect. + * @param action The [KClass] of the [PagingAction] that triggers the effect. + * @param state The [KClass] of the [PagingState] that triggers the effect. + * @param effect The [Effect] to be invoked. + * @return The [PagerBuilder] instance for chaining. + */ + fun , S : PagingState> effect( + action: KClass>, + state: KClass>, + effect: Effect + ) = apply { + this.effectsHolder.put(action, state, effect) + } + + /** + * Sets the [LoadNextEffect] for the pager. + * + * @param effect The [LoadNextEffect] to be used for loading the next page of data. + * @return The [PagerBuilder] instance for chaining. + */ + fun loadNextEffect(effect: LoadNextEffect) = apply { this.loadNextEffect = effect } + + fun appLoadEffect(effect: AppLoadEffect) = apply { this.appLoadEffect = effect } + fun userLoadEffect(effect: UserLoadEffect) = apply { this.userLoadEffect = effect } + fun userLoadMoreEffect(effect: UserLoadMoreEffect) = apply { this.userLoadMoreEffect = effect } + + /** + * Adds a [Middleware] to the pager. + * + * @param middleware The [Middleware] to be added. + * @return The [PagerBuilder] instance for chaining. + */ + fun middleware(middleware: Middleware) = apply { + this.middleware.add(middleware) + } + + /** + * Sets the [Logger] for the pager. + * + * @param logger The [Logger] to be used for logging. + * @return The [PagerBuilder] instance for chaining. + */ + fun logger(logger: Logger) = apply { this.loggerInjector.instance = logger } + + /** + * Sets the default [Logger] for the pager. + * + * @return The [PagerBuilder] instance for chaining. + */ + fun defaultLogger() = apply { this.loggerInjector.instance = DefaultLogger() } + + /** + * Sets the [PagingConfig] for the pager. + * + * @param pagingConfig The [PagingConfig] to be used for configuring the paging behavior. + * @return The [PagerBuilder] instance for chaining. + */ + fun pagingConfig(pagingConfig: PagingConfig) = apply { this.pagingConfigInjector.instance = pagingConfig } + + /** + * Sets the maximum size of the pager buffer. + * + * @param maxSize The maximum size of the pager buffer. + * @return The [PagerBuilder] instance for chaining. + */ + fun pagerBufferMaxSize(maxSize: Int) = apply { this.mutablePagingBufferInjector.instance = RealMutablePagingBuffer(maxSize) } + + /** + * Sets the [InsertionStrategy] for the pager. + * + * @param insertionStrategy The [InsertionStrategy] to be used for inserting new data into the pager buffer. + * @return The [PagerBuilder] instance for chaining. + */ + fun insertionStrategy(insertionStrategy: InsertionStrategy) = apply { this.insertionStrategyInjector.instance = insertionStrategy } + + fun pagingSourceCollector(pagingSourceCollector: PagingSourceCollector) = apply { this.pagingSourceCollectorInjector.instance = pagingSourceCollector } + + fun pagingSource(pagingSource: PagingSource) = apply { this.pagingSourceInjector.instance = pagingSource } + + fun defaultPagingSource(streamProvider: PagingSourceStreamProvider) = apply { + this.pagingSourceInjector.instance = DefaultPagingSource(streamProvider) + } + + @OptIn(ExperimentalStoreApi::class) + fun mutableStorePagingSource(store: MutableStore, PagingData>, factory: () -> StorePagingSourceKeyFactory) = apply { + this.pagingSourceInjector.instance = DefaultPagingSource( + streamProvider = store.pagingSourceStreamProvider( + keyFactory = factory() + ) + ) + } + + fun storePagingSource(store: Store, PagingData>, factory: () -> StorePagingSourceKeyFactory) = apply { + this.pagingSourceInjector.instance = DefaultPagingSource( + streamProvider = store.pagingSourceStreamProvider( + keyFactory = factory() + ) + ) + } + + private fun provideDefaultEffects() { + this.effectsHolder.put(PagingAction.UpdateData::class, PagingState.Data.Idle::class, this.loadNextEffect) + this.effectsHolder.put(PagingAction.Load::class, PagingState::class, this.appLoadEffect) + this.effectsHolder.put(PagingAction.User.Load::class, PagingState.Loading::class, this.userLoadEffect) + this.effectsHolder.put(PagingAction.User.Load::class, PagingState.Data.LoadingMore::class, this.userLoadMoreEffect) + } + + private fun provideDispatcher() { + val effectsLauncher = EffectsLauncher(effectsHolder) + + val dispatcher = RealDispatcher( + stateManager = stateManager, + middleware = middleware, + reducer = reducer, + effectsLauncher = effectsLauncher, + childScope = childScope + ) + + dispatcherInjector.instance = dispatcher + } + + private fun provideQueueManager() { + val queueManager = RealQueueManager( + pagingConfigInjector = pagingConfigInjector, + loggerInjector = loggerInjector, + dispatcherInjector = dispatcherInjector, + fetchingStrategy = fetchingStrategyInjector.inject(), + pagingBuffer = mutablePagingBufferInjector.inject(), + anchorPosition = anchorPosition, + stateManager = stateManager + ) + + queueManagerInjector.instance = queueManager + } + + /** + * Builds and returns the [Pager] instance. + * + * @return The created [Pager] instance. + */ + fun build(): Pager { + + provideDefaultEffects() + provideDispatcher() + provideQueueManager() + + return RealPager( + initialKey = initialKey, + dispatcher = dispatcherInjector.inject(), + pagingConfigInjector = pagingConfigInjector, + stateManager = stateManager, + ) + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingAction.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingAction.kt new file mode 100644 index 000000000..04f7e45e7 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingAction.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.paging.core + +/** + * Defines the actions that can be dispatched to modify the paging state. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +sealed interface PagingAction, out K : Any, out P : Any, out D : Any, out E : Any, out A : Any> { + + /** + * Defines user-initiated actions. + */ + sealed interface User, out K : Any, out P : Any, out D : Any, out E : Any, out A : Any> : PagingAction { + + /** + * Represents a user-initiated action to load data for a specific page key. + * + * @param key The page key to load data for. + */ + data class Load, out K : Any, out P : Any, out D : Any, out E : Any, out A : Any>( + val key: PagingKey, + ) : User + + /** + * Represents a custom user-initiated action. + * + * @param action The custom action payload. + */ + data class Custom, out K : Any, out P : Any, out D : Any, out E : Any, out A : Any>( + val action: A + ) : User + } + + + /** + * Represents an app-initiated action to load data for a specific page key. + * + * @param key The page key to load data for. + */ + data class Load, K : Any, P : Any, D : Any, E : Any, A : Any>( + val key: PagingKey, + ) : PagingAction + + /** + * Represents an app-initiated action to update the paging state with loaded data. + * + * @param params The parameters associated with the loaded data. + * @param data The loaded data. + */ + data class UpdateData, K : Any, P : Any, D : Any, E : Any, A : Any>( + val params: PagingSource.LoadParams, + val data: PagingSource.LoadResult.Data + ) : PagingAction + + /** + * Represents an app-initiated action to update the paging state with an error. + * + * @param params The parameters associated with the error. + * @param error The error that occurred. + */ + data class UpdateError, K : Any, P : Any, D : Any, E : Any, A : Any>( + val params: PagingSource.LoadParams, + val error: PagingSource.LoadResult.Error + ) : PagingAction + +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingBuffer.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingBuffer.kt new file mode 100644 index 000000000..38a6c7fed --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingBuffer.kt @@ -0,0 +1,59 @@ +package org.mobilenativefoundation.paging.core + +/** + * A custom data structure for efficiently storing and retrieving paging data. + * + * The [PagingBuffer] is responsible for caching and providing access to the loaded pages of data. + * It allows retrieving data by load parameters, page key, or accessing the entire buffer. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + */ +interface PagingBuffer, K : Any, P : Any, D : Any> { + /** + * Retrieves the data associated with the specified [PagingSource.LoadParams]. + * + * @param params The [PagingSource.LoadParams] to retrieve the data for. + * @return The [PagingSource.LoadResult.Data] associated with the specified [params], or `null` if not found. + */ + fun get(params: PagingSource.LoadParams): PagingSource.LoadResult.Data? + + /** + * Retrieves the data associated with the specified [PagingKey]. + * + * @param key The [PagingKey] to retrieve the data for. + * @return The [PagingSource.LoadResult.Data] associated with the specified [key], or `null` if not found. + */ + fun get(key: PagingKey): PagingSource.LoadResult.Data? + + /** + * Retrieves the data at the head of the buffer. + * + * @return The [PagingSource.LoadResult.Data] at the head of the buffer, or `null` if the buffer is empty. + */ + fun head(): PagingSource.LoadResult.Data? + + /** + * Retrieves all the data in the buffer as a list. + * + * @return A list of all the [PagingSource.LoadResult.Data] in the buffer. + */ + fun getAll(): List> + + /** + * Checks if the buffer is empty. + * + * @return `true` if the buffer is empty, `false` otherwise. + */ + fun isEmpty(): Boolean + + /** + * Returns the index of the data associated with the specified [PagingKey] in the buffer. + * + * @param key The [PagingKey] to find the index for. + * @return The index of the data associated with the specified [key], or -1 if not found. + */ + fun indexOf(key: PagingKey): Int +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingConfig.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingConfig.kt new file mode 100644 index 000000000..481057c81 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingConfig.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents the configuration for paging behavior. + * + * @property pageSize The number of items to load per page. + * @property prefetchDistance The distance from the edge of the loaded data at which to prefetch more data. + * @property insertionStrategy The strategy for inserting new data into the paging buffer. + */ +data class PagingConfig( + val pageSize: Int, + val prefetchDistance: Int, + val insertionStrategy: InsertionStrategy +) { + /** + * Represents different insertion strategies for adding new data to the paging buffer. + */ + enum class InsertionStrategy { + /** + * Appends new data to the end of the buffer. + */ + APPEND, + + /** + * Prepends new data to the beginning of the buffer. + */ + PREPEND, + + /** + * Replaces the existing data in the buffer with the new data. + */ + REPLACE, + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingData.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingData.kt new file mode 100644 index 000000000..45184330d --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingData.kt @@ -0,0 +1,49 @@ +package org.mobilenativefoundation.paging.core + + +/** + * Represents paging data that can be either a single item or a collection of items. + * + * @param Id The type of the unique identifier for each item. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + */ +sealed interface PagingData { + + /** + * Represents a single item of paging data. + * + * @param Id The type of the unique identifier for the item. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with the item. + * @param D The type of the data item. + * @property id The unique identifier of the item. + * @property data The data item. + */ + data class Single( + val id: Id, + val data: D + ) : PagingData + + /** + * Represents a collection of paging data items. + * + * @param Id The type of the unique identifier for each item. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @property items The list of paging data items in the collection. + * @property itemsBefore The number of items before the current collection, if known. + * @property itemsAfter The number of items after the current collection, if known. + * @property prevKey The paging key of the previous page of data. + * @property nextKey The paging key of the next page of data, if available. + */ + data class Collection( + val items: List>, + val itemsBefore: Int?, + val itemsAfter: Int?, + val prevKey: PagingKey, + val nextKey: PagingKey?, + ) : PagingData +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingItems.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingItems.kt new file mode 100644 index 000000000..c1077a8b2 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingItems.kt @@ -0,0 +1,14 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a list of paging items. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @property data The list of [PagingData.Single] items representing the paging data. + */ +data class PagingItems, K : Any, P : Any, D : Any>( + val data: List> +) diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingKey.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingKey.kt new file mode 100644 index 000000000..06d454ec2 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingKey.kt @@ -0,0 +1,14 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a key used for paging along with its associated parameters. + * + * @param K The type of the key. + * @param P The type of the parameters. + * @property key The key value used for paging. + * @property params The parameters associated with the key. + */ +data class PagingKey( + val key: K, + val params: P, +) \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSource.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSource.kt new file mode 100644 index 000000000..300a963ac --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSource.kt @@ -0,0 +1,61 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.flow.Flow + +/** + * Represents a data source that provides paged data. + * + * A [PagingSource] is responsible for loading pages of data from a specific data source, + * such as a database or a network API. It emits a stream of [LoadResult] instances that + * represent the loaded data or any errors that occurred during loading. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of custom errors that can occur during loading. + */ +interface PagingSource, K : Any, P : Any, D : Any, E : Any> { + /** + * Returns a flow of [LoadResult] instances for the specified [LoadParams]. + * + * This function is called by the paging library to load pages of data. It takes the + * [LoadParams] as input and returns a flow of [LoadResult] instances representing + * the loaded data or any errors that occurred. + * + * @param params The [LoadParams] specifying the page key and refresh state. + * @return A flow of [LoadResult] instances representing the loaded data or errors. + */ + fun stream(params: LoadParams): Flow> + + /** + * Represents the parameters for loading a page of data. + * + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @property key The [PagingKey] identifying the page to load. + * @property refresh Indicates whether to refresh the data or load a new page. + */ + data class LoadParams( + val key: PagingKey, + val refresh: Boolean, + ) + + /** + * Represents the result of loading a page of data. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of custom errors that can occur during loading. + */ + sealed class LoadResult, out K : Any, out P : Any, out D : Any, out E : Any> { + sealed class Error, K : Any, P : Any, D : Any, E : Any> : LoadResult() { + data class Exception, K : Any, P : Any, D : Any, E : Any>(val error: Throwable) : Error() + data class Custom, K : Any, P : Any, D : Any, E : Any>(val error: E) : Error() + } + + data class Data, K : Any, P : Any, D : Any>(val collection: PagingData.Collection) : LoadResult() + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSourceCollector.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSourceCollector.kt new file mode 100644 index 000000000..990c6f3ab --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSourceCollector.kt @@ -0,0 +1,30 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.flow.Flow + +/** + * Represents a collector for [PagingSource.LoadResult] objects. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched. + */ +interface PagingSourceCollector, K : Any, P : Any, D : Any, E : Any, A : Any> { + /** + * Collects the load results from the [PagingSource] and dispatches appropriate [PagingAction] objects. + * + * @param params The [PagingSource.LoadParams] associated with the load operation. + * @param results The flow of [PagingSource.LoadResult] instances representing the load results. + * @param state The current [PagingState] when collecting the load results. + * @param dispatch The function to dispatch [PagingAction] instances based on the load results. + */ + suspend operator fun invoke( + params: PagingSource.LoadParams, + results: Flow>, + state: PagingState, + dispatch: (action: PagingAction) -> Unit + ) +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSourceStreamProvider.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSourceStreamProvider.kt new file mode 100644 index 000000000..ec53dfca8 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingSourceStreamProvider.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.flow.Flow + +/** + * Represents a provider of [PagingSource.LoadResult] streams. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + */ +interface PagingSourceStreamProvider, K : Any, P : Any, D : Any, E : Any> { + /** + * Provides a flow of [PagingSource.LoadResult] instances for the specified [PagingSource.LoadParams]. + * + * @param params The [PagingSource.LoadParams] for which to provide the load result stream. + * @return A flow of [PagingSource.LoadResult] instances representing the load results. + */ + fun provide(params: PagingSource.LoadParams): Flow> +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingState.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingState.kt new file mode 100644 index 000000000..4754fe59c --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/PagingState.kt @@ -0,0 +1,79 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents the current state of the paging data. + * + * The paging state can be in different stages, such as [Initial], [Loading], [Error], or [Data]. + * It can contain the current key, prefetch position, errors, and data, such as loaded items and the next key. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + */ +sealed interface PagingState, out K : Any, out P : Any, out D : Any, out E : Any> { + val currentKey: PagingKey + val prefetchPosition: PagingKey? + + data class Initial, K : Any, P : Any, D : Any, E : Any>( + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : PagingState + + data class Loading, K : Any, P : Any, D : Any, E : Any>( + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : PagingState + + sealed interface Error, out K : Any, out P : Any, out D : Any, out E : Any, out RE : Any> : PagingState { + val error: RE + + data class Exception, K : Any, P : Any, D : Any, E : Any>( + override val error: Throwable, + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : Error + + data class Custom, K : Any, P : Any, D : Any, E : Any>( + override val error: E, + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : Error + } + + sealed interface Data, K : Any, P : Any, D : Any, E : Any> : PagingState { + val data: List> + val itemsBefore: Int? + val itemsAfter: Int? + val nextKey: PagingKey? + + data class Idle, K : Any, P : Any, D : Any, E : Any>( + override val data: List>, + override val itemsBefore: Int?, + override val itemsAfter: Int?, + override val nextKey: PagingKey?, + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : Data + + data class LoadingMore, K : Any, P : Any, D : Any, E : Any>( + override val data: List>, + override val itemsBefore: Int?, + override val itemsAfter: Int?, + override val nextKey: PagingKey?, + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : Data + + data class ErrorLoadingMore, K : Any, P : Any, D : Any, E : Any, RE : Any>( + override val error: RE, + override val data: List>, + override val itemsBefore: Int?, + override val itemsAfter: Int?, + override val nextKey: PagingKey?, + override val currentKey: PagingKey, + override val prefetchPosition: PagingKey? + ) : Data, Error + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Reducer.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Reducer.kt new file mode 100644 index 000000000..414205f05 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/Reducer.kt @@ -0,0 +1,29 @@ +package org.mobilenativefoundation.paging.core + +/** + * The [Reducer] is responsible for taking the current [PagingState] and a dispatched [PagingAction], + * and producing a new [PagingState] based on the action and the current state. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +interface Reducer, K : Any, P : Any, D : Any, E : Any, A : Any> { + /** + * Reduces the current [PagingState] based on the dispatched [PagingAction] and returns a new [PagingState]. + * + * This function is called whenever a [PagingAction] is dispatched to update the paging state. + * It should handle the action and produce a new state based on the current state and the action. + * + * @param action The dispatched [PagingAction] to be reduced. + * @param state The current [PagingState] before applying the action. + * @return The new [PagingState] after applying the action to the current state. + */ + suspend fun reduce( + action: PagingAction, + state: PagingState + ): PagingState +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/StorePagingSourceKeyFactory.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/StorePagingSourceKeyFactory.kt new file mode 100644 index 000000000..0a9ca2d7d --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/StorePagingSourceKeyFactory.kt @@ -0,0 +1,5 @@ +package org.mobilenativefoundation.paging.core + +fun interface StorePagingSourceKeyFactory, K : Any, P : Any, D : Any> { + fun createKeyFor(single: PagingData.Single): PagingKey +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/UserCustomActionReducer.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/UserCustomActionReducer.kt new file mode 100644 index 000000000..acf945cbe --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/UserCustomActionReducer.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.paging.core + +/** + * Represents a reducer for handling [PagingAction.User.Custom] actions. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched. + */ +interface UserCustomActionReducer, K : Any, P : Any, D : Any, E : Any, A : Any> { + /** + * Reduces the current [PagingState] based on the custom user action. + * + * @param action The custom user action to reduce. + * @param state The current [PagingState] before applying the action. + * @return The new [PagingState] after applying the custom user action. + */ + fun reduce( + action: PagingAction.User.Custom, + state: PagingState + ): PagingState +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/effects.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/effects.kt new file mode 100644 index 000000000..ce9a1546f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/effects.kt @@ -0,0 +1,49 @@ +package org.mobilenativefoundation.paging.core + +/** + * A type alias for an [Effect] that loads the next page of data when the paging state is [PagingState.Data.Idle]. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched. + */ +typealias LoadNextEffect = Effect, PagingState.Data.Idle> + +/** + * A type alias for an [Effect] that loads data when a [PagingAction.Load] action is dispatched and the paging state is [PagingState]. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +typealias AppLoadEffect = Effect, PagingState> + +/** + * A type alias for an [Effect] that loads data when a [PagingAction.User.Load] action is dispatched and the paging state is [PagingState.Loading]. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +typealias UserLoadEffect = Effect, PagingState.Loading> + +/** + * A type alias for an [Effect] that loads more data when a [PagingAction.User.Load] action is dispatched and the paging state is [PagingState.Data.LoadingMore]. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +typealias UserLoadMoreEffect = Effect, PagingState.Data.LoadingMore> \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Constants.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Constants.kt new file mode 100644 index 000000000..1876c14a9 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Constants.kt @@ -0,0 +1,5 @@ +package org.mobilenativefoundation.paging.core.impl + +internal object Constants { + const val UNCHECKED_CAST = "UNCHECKED_CAST" +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultAggregatingStrategy.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultAggregatingStrategy.kt new file mode 100644 index 000000000..4b0060ef3 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultAggregatingStrategy.kt @@ -0,0 +1,35 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.AggregatingStrategy +import org.mobilenativefoundation.paging.core.PagingBuffer +import org.mobilenativefoundation.paging.core.PagingConfig +import org.mobilenativefoundation.paging.core.PagingConfig.InsertionStrategy +import org.mobilenativefoundation.paging.core.PagingData +import org.mobilenativefoundation.paging.core.PagingItems +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.paging.core.PagingSource + +class DefaultAggregatingStrategy, K : Any, P : Any, D : Any> : AggregatingStrategy { + override fun aggregate(anchorPosition: PagingKey, prefetchPosition: PagingKey?, pagingConfig: PagingConfig, pagingBuffer: PagingBuffer): PagingItems { + if (pagingBuffer.isEmpty()) return PagingItems(emptyList()) + + val orderedItems = mutableListOf>() + + var currentPage: PagingSource.LoadResult.Data? = pagingBuffer.head() + + while (currentPage != null) { + when (pagingConfig.insertionStrategy) { + InsertionStrategy.APPEND -> orderedItems.addAll(currentPage.collection.items) + InsertionStrategy.PREPEND -> orderedItems.addAll(0, currentPage.collection.items) + InsertionStrategy.REPLACE -> { + orderedItems.clear() + orderedItems.addAll(currentPage.collection.items) + } + } + + currentPage = currentPage.collection.nextKey?.let { pagingBuffer.get(it) } + } + + return PagingItems(orderedItems) + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultAppLoadEffect.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultAppLoadEffect.kt new file mode 100644 index 000000000..521a1b438 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultAppLoadEffect.kt @@ -0,0 +1,43 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.AppLoadEffect +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingSourceCollector +import org.mobilenativefoundation.paging.core.PagingState + +class DefaultAppLoadEffect, K : Any, P : Any, D : Any, E : Any, A : Any>( + loggerInjector: OptionalInjector, + dispatcherInjector: Injector>, + pagingSourceCollectorInjector: Injector>, + pagingSourceInjector: Injector>, + private val jobCoordinator: JobCoordinator, + private val stateManager: StateManager, +) : AppLoadEffect { + private val logger = lazy { loggerInjector.inject() } + private val dispatcher = lazy { dispatcherInjector.inject() } + private val pagingSourceCollector = lazy { pagingSourceCollectorInjector.inject() } + private val pagingSource = lazy { pagingSourceInjector.inject() } + + override fun invoke(action: PagingAction.Load, state: PagingState, dispatch: (PagingAction) -> Unit) { + logger.value?.log( + """Running post reducer effect: + Effect: App load + State: $state + Action: $action + """.trimIndent(), + ) + + jobCoordinator.launchIfNotActive(action.key) { + val params = PagingSource.LoadParams(action.key, true) + pagingSourceCollector.value( + params, + pagingSource.value.stream(params), + stateManager.state.value, + dispatcher.value::dispatch + ) + } + } + +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultFetchingStrategy.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultFetchingStrategy.kt new file mode 100644 index 000000000..bbec5c520 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultFetchingStrategy.kt @@ -0,0 +1,26 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.FetchingStrategy +import org.mobilenativefoundation.paging.core.PagingBuffer +import org.mobilenativefoundation.paging.core.PagingConfig +import org.mobilenativefoundation.paging.core.PagingKey +import kotlin.math.max + +class DefaultFetchingStrategy, K : Any, P : Any, D : Any> : FetchingStrategy { + override fun shouldFetch(anchorPosition: PagingKey, prefetchPosition: PagingKey?, pagingConfig: PagingConfig, pagingBuffer: PagingBuffer): Boolean { + if (prefetchPosition == null) return true + + val indexOfAnchor = pagingBuffer.indexOf(anchorPosition) + val indexOfPrefetch = pagingBuffer.indexOf(prefetchPosition) + + if ((indexOfAnchor == -1 && indexOfPrefetch == -1) || indexOfPrefetch == -1) return true + + val effectiveAnchor = max(indexOfAnchor, 0) + val effectivePrefetch = (indexOfPrefetch + 1) * pagingConfig.pageSize + + val shouldFetch = effectivePrefetch - effectiveAnchor < pagingConfig.prefetchDistance + + return shouldFetch + } + +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultLoadNextEffect.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultLoadNextEffect.kt new file mode 100644 index 000000000..892bc3d47 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultLoadNextEffect.kt @@ -0,0 +1,30 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.LoadNextEffect +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingState + +class DefaultLoadNextEffect, K : Any, P : Any, D : Any, E : Any, A : Any>( + loggerInjector: OptionalInjector, + queueManagerInjector: Injector>, +) : LoadNextEffect { + + private val logger = lazy { loggerInjector.inject() } + private val queueManager = lazy { queueManagerInjector.inject() } + + override fun invoke(action: PagingAction.UpdateData, state: PagingState.Data.Idle, dispatch: (PagingAction) -> Unit) { + logger.value?.log( + """ + Running post reducer effect: + Effect: Load next + State: $state + Action: $action + """.trimIndent(), + ) + + action.data.collection.nextKey?.key?.let { + queueManager.value.enqueue(action.data.collection.nextKey) + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultLogger.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultLogger.kt new file mode 100644 index 000000000..ec45f9525 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultLogger.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.paging.core.impl + +import co.touchlab.kermit.CommonWriter +import org.mobilenativefoundation.paging.core.Logger + +class DefaultLogger : Logger { + override fun log(message: String) { + logger.d( + """ + + $message + + """.trimIndent(), + ) + } + + private val logger = + co.touchlab.kermit.Logger.apply { + setLogWriters(listOf(CommonWriter())) + setTag("org.mobilenativefoundation.paging") + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultPagingSource.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultPagingSource.kt new file mode 100644 index 000000000..1959e6de5 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultPagingSource.kt @@ -0,0 +1,19 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingSourceStreamProvider + +class DefaultPagingSource, K : Any, P : Any, D : Any, E : Any, A : Any>( + private val streamProvider: PagingSourceStreamProvider +) : PagingSource { + private val streams = mutableMapOf, Flow>>() + + override fun stream(params: PagingSource.LoadParams): Flow> { + if (params.key !in streams) { + streams[params.key] = streamProvider.provide(params) + } + return streams[params.key]!! + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultPagingSourceCollector.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultPagingSourceCollector.kt new file mode 100644 index 000000000..4039682c4 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultPagingSourceCollector.kt @@ -0,0 +1,23 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingSourceCollector +import org.mobilenativefoundation.paging.core.PagingState + +class DefaultPagingSourceCollector, K : Any, P : Any, D : Any, E : Any, A : Any> : PagingSourceCollector { + override suspend fun invoke( + params: PagingSource.LoadParams, + results: Flow>, + state: PagingState, + dispatch: (action: PagingAction) -> Unit + ) { + results.collect { result -> + when (result) { + is PagingSource.LoadResult.Data -> dispatch(PagingAction.UpdateData(params, result)) + is PagingSource.LoadResult.Error -> dispatch(PagingAction.UpdateError(params, result)) + } + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultReducer.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultReducer.kt new file mode 100644 index 000000000..84c960b6d --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultReducer.kt @@ -0,0 +1,176 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.mobilenativefoundation.paging.core.AggregatingStrategy +import org.mobilenativefoundation.paging.core.ErrorHandlingStrategy +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.MutablePagingBuffer +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingConfig +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.Reducer +import org.mobilenativefoundation.paging.core.UserCustomActionReducer + +class DefaultReducer, K : Any, P : Any, D : Any, E : Any, A : Any>( + private val childScope: CoroutineScope, + private val initialKey: PagingKey, + private val dispatcherInjector: Injector>, + pagingConfigInjector: Injector, + private val userCustomActionReducer: UserCustomActionReducer?, + private val anchorPosition: StateFlow>, + loggerInjector: OptionalInjector, + private val errorHandlingStrategy: ErrorHandlingStrategy, + private val mutablePagingBuffer: MutablePagingBuffer, + private val aggregatingStrategy: AggregatingStrategy, + private val retriesManager: RetriesManager, + private val jobCoordinator: JobCoordinator, +) : Reducer { + + private val logger = lazy { loggerInjector.inject() } + private val pagingConfig = lazy { pagingConfigInjector.inject() } + private val dispatcher = lazy { dispatcherInjector.inject() } + + override suspend fun reduce(action: PagingAction, state: PagingState): PagingState { + logger.value?.log( + """ + Reducing: + Action: $action + Previous state: $state + """.trimIndent(), + ) + + return when (action) { + is PagingAction.UpdateData -> reduceUpdateDataAction(action, state) + is PagingAction.User.Custom -> reduceUserCustomAction(action, state) + is PagingAction.User.Load -> reduceUserLoadAction(action, state) + is PagingAction.UpdateError -> reduceUpdateErrorAction(action, state) + is PagingAction.Load -> reduceLoadAction(action, state) + } + } + + private fun reduceUpdateDataAction(action: PagingAction.UpdateData, prevState: PagingState): PagingState { + mutablePagingBuffer.put(action.params, action.data) + + val nextPagingItems = aggregatingStrategy.aggregate( + anchorPosition = anchorPosition.value, + prefetchPosition = action.params.key, + pagingConfig = pagingConfig.value, + pagingBuffer = mutablePagingBuffer + ) + + resetRetriesFor(action.params) + + return PagingState.Data.Idle( + data = nextPagingItems.data, + itemsBefore = action.data.collection.itemsBefore, + itemsAfter = action.data.collection.itemsAfter, + currentKey = action.data.collection.prevKey, + nextKey = action.data.collection.nextKey, + prefetchPosition = action.params.key + ) + + } + + private suspend fun reduceUpdateErrorAction(action: PagingAction.UpdateError, prevState: PagingState): PagingState { + return when (errorHandlingStrategy) { + ErrorHandlingStrategy.Ignore -> prevState + ErrorHandlingStrategy.PassThrough -> reduceUpdateErrorActionWithPassThrough(action, prevState) + is ErrorHandlingStrategy.RetryLast -> retryLast(errorHandlingStrategy.maxRetries, action, prevState) + } + } + + private suspend fun retryLast(maxRetries: Int, action: PagingAction.UpdateError, prevState: PagingState): PagingState { + + val retries = retriesManager.getRetriesFor(action.params) + + return if (retries < maxRetries) { + // Retry without emitting the error + + jobCoordinator.cancel(action.params.key) + retriesManager.incrementRetriesFor(action.params) + dispatcher.value.dispatch(PagingAction.Load(action.params.key)) + prevState + } else { + // Emit the error and reset the counter + + retriesManager.resetRetriesFor(action.params) + if (prevState is PagingState.Data) { + PagingState.Data.ErrorLoadingMore( + error = action.error, + data = prevState.data, + itemsBefore = prevState.itemsBefore, + itemsAfter = prevState.itemsAfter, + nextKey = prevState.nextKey, + currentKey = prevState.currentKey, + prefetchPosition = prevState.prefetchPosition + ) + } else { + when (action.error) { + is PagingSource.LoadResult.Error.Custom -> PagingState.Error.Custom(action.error.error, action.params.key, prevState.prefetchPosition) + is PagingSource.LoadResult.Error.Exception -> PagingState.Error.Exception(action.error.error, action.params.key, prevState.prefetchPosition) + } + } + } + } + + private fun reduceUpdateErrorActionWithPassThrough(action: PagingAction.UpdateError, prevState: PagingState): PagingState { + // Emitting it, but not doing anything else + + val errorState: PagingState.Error = when (action.error) { + is PagingSource.LoadResult.Error.Custom -> PagingState.Error.Custom(action.error.error, action.params.key, prevState.prefetchPosition) + is PagingSource.LoadResult.Error.Exception -> PagingState.Error.Exception(action.error.error, action.params.key, prevState.prefetchPosition) + } + + return if (prevState is PagingState.Data) { + PagingState.Data.ErrorLoadingMore( + error = errorState, + data = prevState.data, + itemsBefore = prevState.itemsBefore, + itemsAfter = prevState.itemsAfter, + currentKey = prevState.currentKey, + nextKey = prevState.nextKey, + prefetchPosition = prevState.prefetchPosition, + ) + } else { + errorState + } + } + + + private fun reduceUserCustomAction(action: PagingAction.User.Custom, prevState: PagingState): PagingState { + return userCustomActionReducer?.reduce(action, prevState) ?: prevState + } + + private fun reduceLoadActionAndDataState(prevState: PagingState.Data) = PagingState.Data.LoadingMore( + data = prevState.data, + itemsBefore = prevState.itemsBefore, + itemsAfter = prevState.itemsAfter, + currentKey = prevState.currentKey, + nextKey = prevState.nextKey, + prefetchPosition = prevState.prefetchPosition + ) + + private fun reduceLoadActionAndNonDataState(key: PagingKey, prevState: PagingState) = PagingState.Loading( + currentKey = key, + prefetchPosition = prevState.prefetchPosition + ) + + private fun reduceLoadAction(action: PagingAction.Load, prevState: PagingState): PagingState { + return if (prevState is PagingState.Data) reduceLoadActionAndDataState(prevState) else reduceLoadActionAndNonDataState(action.key, prevState) + } + + + private fun reduceUserLoadAction(action: PagingAction.User.Load, prevState: PagingState): PagingState { + return if (prevState is PagingState.Data) reduceLoadActionAndDataState(prevState) else reduceLoadActionAndNonDataState(action.key, prevState) + } + + private fun resetRetriesFor(params: PagingSource.LoadParams) { + childScope.launch { + retriesManager.resetRetriesFor(params) + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultUserLoadEffect.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultUserLoadEffect.kt new file mode 100644 index 000000000..bf9221ba6 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultUserLoadEffect.kt @@ -0,0 +1,43 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingSourceCollector +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.UserLoadEffect + +class DefaultUserLoadEffect, K : Any, P : Any, D : Any, E : Any, A : Any>( + loggerInjector: OptionalInjector, + dispatcherInjector: Injector>, + pagingSourceCollectorInjector: Injector>, + pagingSourceInjector: Injector>, + private val jobCoordinator: JobCoordinator, + private val stateManager: StateManager, +) : UserLoadEffect { + private val logger = lazy { loggerInjector.inject() } + private val dispatcher = lazy { dispatcherInjector.inject() } + private val pagingSourceCollector = lazy { pagingSourceCollectorInjector.inject() } + private val pagingSource = lazy { pagingSourceInjector.inject() } + + override fun invoke(action: PagingAction.User.Load, state: PagingState.Loading, dispatch: (PagingAction) -> Unit) { + logger.value?.log( + """Running post reducer effect: + Effect: User load + State: $state + Action: $action + """.trimIndent(), + ) + + jobCoordinator.launch(action.key) { + val params = PagingSource.LoadParams(action.key, true) + pagingSourceCollector.value( + params, + pagingSource.value.stream(params), + stateManager.state.value, + dispatcher.value::dispatch + ) + } + } + +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultUserLoadMoreEffect.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultUserLoadMoreEffect.kt new file mode 100644 index 000000000..01740bb25 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/DefaultUserLoadMoreEffect.kt @@ -0,0 +1,43 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingSourceCollector +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.UserLoadMoreEffect + +class DefaultUserLoadMoreEffect, K : Any, P : Any, D : Any, E : Any, A : Any>( + loggerInjector: OptionalInjector, + dispatcherInjector: Injector>, + pagingSourceCollectorInjector: Injector>, + pagingSourceInjector: Injector>, + private val jobCoordinator: JobCoordinator, + private val stateManager: StateManager, +) : UserLoadMoreEffect { + private val logger = lazy { loggerInjector.inject() } + private val dispatcher = lazy { dispatcherInjector.inject() } + private val pagingSourceCollector = lazy { pagingSourceCollectorInjector.inject() } + private val pagingSource = lazy { pagingSourceInjector.inject() } + + override fun invoke(action: PagingAction.User.Load, state: PagingState.Data.LoadingMore, dispatch: (PagingAction) -> Unit) { + logger.value?.log( + """Running post reducer effect: + Effect: User load more + State: $state + Action: $action + """.trimIndent(), + ) + + jobCoordinator.launch(action.key) { + val params = PagingSource.LoadParams(action.key, true) + pagingSourceCollector.value( + params, + pagingSource.value.stream(params), + stateManager.state.value, + dispatcher.value::dispatch + ) + } + } + +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Dispatcher.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Dispatcher.kt new file mode 100644 index 000000000..cf386be3f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Dispatcher.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.PagingAction + +/** + * A dispatcher for handling paging actions and dispatching them to the appropriate middleware and reducer. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +interface Dispatcher, K : Any, P : Any, D : Any, E : Any, A : Any> { + + /** + * Dispatches a paging action to the middleware and reducer chain. + * + * @param PA The type of the paging action being dispatched. + * @param action The paging action to dispatch. + * @param index The index of the middleware to start dispatching from. Default is 0. + */ + fun > dispatch(action: PA, index: Int = 0) +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/EffectsHolder.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/EffectsHolder.kt new file mode 100644 index 000000000..b538f248f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/EffectsHolder.kt @@ -0,0 +1,79 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.Effect +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.impl.Constants.UNCHECKED_CAST +import kotlin.reflect.KClass + +/** + * A type alias representing a mapping from a [PagingAction] to a list of [Effect]s. + */ +typealias PagingActionToEffects = MutableMap>, MutableList>> + +/** + * A type alias representing a mapping from a [PagingState] to a [PagingActionToEffects] map. + */ +typealias PagingStateToPagingActionToEffects = MutableMap>, PagingActionToEffects> + +/** + * A class for holding and managing effects based on [PagingAction] and [PagingState] types. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + */ +@Suppress(UNCHECKED_CAST) +class EffectsHolder, K : Any, P : Any, D : Any, E : Any, A : Any> { + private val effects: PagingStateToPagingActionToEffects = mutableMapOf() + + /** + * Retrieves the list of effects associated with the specified [PagingAction] and [PagingState] types. + * + * @param PA The type of the [PagingAction]. + * @param S The type of the [PagingState]. + * @param action The [KClass] of the [PagingAction]. + * @param state The [KClass] of the [PagingState]. + * @return The list of effects associated with the specified [PagingAction] and [PagingState] types. + */ + fun , S : PagingState> get( + action: KClass>, + state: KClass> + ): List> { + action as KClass + state as KClass + + return effects[state]?.get(action) as? List> ?: emptyList() + } + + /** + * Adds an effect to the list of effects associated with the specified [PagingAction] and [PagingState] types. + * + * @param PA The type of the [PagingAction]. + * @param S The type of the [PagingState]. + * @param action The [KClass] of the [PagingAction]. + * @param state The [KClass] of the [PagingState]. + * @param effect The effect to add. + */ + fun , S : PagingState> put( + action: KClass>, + state: KClass>, + effect: Effect + ) { + action as KClass + state as KClass + + if (state !in effects) { + effects[state] = mutableMapOf() + } + + if (action !in effects[state]!!) { + effects[state]!![action] = mutableListOf() + } + + effects[state]!![action]!!.add(effect) + } +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/EffectsLauncher.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/EffectsLauncher.kt new file mode 100644 index 000000000..a00e32e7f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/EffectsLauncher.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.impl.Constants.UNCHECKED_CAST +import kotlin.reflect.KClass + +/** + * A class for launching effects based on dispatched [PagingAction]s and the current [PagingState]. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + * @property effectsHolder The [EffectsHolder] instance holding the effects. + */ +@Suppress(UNCHECKED_CAST) +class EffectsLauncher, K : Any, P : Any, D : Any, E : Any, A : Any>( + private val effectsHolder: EffectsHolder +) { + + fun , S : PagingState> launch(action: PA, state: S, dispatch: (PagingAction) -> Unit) { + + effectsHolder.get(action::class, state::class).forEach { effect -> + effect(action, state, dispatch) + } + + effectsHolder.get>(action::class, PagingState::class as KClass>).forEach { effect -> + effect(action, state, dispatch) + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Injector.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Injector.kt new file mode 100644 index 000000000..21a61a1ed --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/Injector.kt @@ -0,0 +1,16 @@ +package org.mobilenativefoundation.paging.core.impl + +/** + * An interface representing an injector for providing instances of a specific type. + * + * @param T The type of the instance to be injected. + */ +interface Injector { + + /** + * Injects an instance of type [T]. + * + * @return The injected instance. + */ + fun inject(): T +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/JobCoordinator.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/JobCoordinator.kt new file mode 100644 index 000000000..5b038d454 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/JobCoordinator.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.CoroutineScope + +interface JobCoordinator { + fun launch(key: Any, block: suspend CoroutineScope.() -> Unit) + fun launchIfNotActive(key: Any, block: suspend CoroutineScope.() -> Unit) + fun cancel(key: Any) + fun cancelAll() +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/OptionalInjector.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/OptionalInjector.kt new file mode 100644 index 000000000..f1117250e --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/OptionalInjector.kt @@ -0,0 +1,15 @@ +package org.mobilenativefoundation.paging.core.impl + +/** + * An interface representing an optional injector for providing instances of a specific type. + * + * @param T The type of the instance to be injected. + */ +interface OptionalInjector { + /** + * Injects an instance of type [T] if available, or returns null. + * + * @return The injected instance or null if not available. + */ + fun inject(): T? +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/QueueManager.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/QueueManager.kt new file mode 100644 index 000000000..567fae056 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/QueueManager.kt @@ -0,0 +1,18 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.PagingKey + +/** + * Represents a manager for the queue of pages to be loaded. + * + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + */ +interface QueueManager { + /** + * Enqueues a page key to be loaded. + * + * @param key The [PagingKey] representing the page to be loaded. + */ + fun enqueue(key: PagingKey) +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealDispatcher.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealDispatcher.kt new file mode 100644 index 000000000..ad8a92539 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealDispatcher.kt @@ -0,0 +1,78 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.mobilenativefoundation.paging.core.Middleware +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.Reducer + +/** + * A real implementation of the [Dispatcher] interface for handling paging actions and managing the paging state. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + * @property stateManager The [StateManager] instance for managing the paging state. + * @property middleware The list of [Middleware] instances to be applied to the dispatched actions. + * @property reducer The [Reducer] instance for reducing the paging state based on the dispatched actions. + * @property effectsLauncher The [EffectsLauncher] instance for launching effects based on the dispatched actions and the current state. + * @property childScope The [CoroutineScope] in which the dispatcher will operate. + */ +class RealDispatcher, K : Any, P : Any, D : Any, E : Any, A : Any>( + private val stateManager: StateManager, + private val middleware: List>, + private val reducer: Reducer, + private val effectsLauncher: EffectsLauncher, + private val childScope: CoroutineScope, +) : Dispatcher { + + /** + * Dispatches a paging action to the middleware and reducer chain. + * + * @param PA The type of the paging action being dispatched. + * @param action The paging action to dispatch. + * @param index The index of the middleware to start dispatching from. + */ + override fun > dispatch(action: PA, index: Int) { + if (index < middleware.size) { + + childScope.launch { + middleware[index].apply(action) { nextAction -> + dispatch(nextAction, index + 1) + } + } + + } else { + childScope.launch { + reduceAndLaunchEffects(action) + } + } + } + + /** + * Reduces the paging state based on the dispatched action and launches the corresponding effects. + * + * @param PA The type of the paging action being dispatched. + * @param action The paging action to reduce and launch effects for. + */ + private suspend fun > reduceAndLaunchEffects(action: PA) { + val prevState = stateManager.state.value + val nextState = reducer.reduce(action, prevState) + + stateManager.update(nextState) + + when (nextState) { + is PagingState.Initial -> effectsLauncher.launch(action, nextState, ::dispatch) + is PagingState.Data.Idle -> effectsLauncher.launch(action, nextState, ::dispatch) + is PagingState.Data.ErrorLoadingMore -> effectsLauncher.launch(action, nextState, ::dispatch) + is PagingState.Data.LoadingMore -> effectsLauncher.launch(action, nextState, ::dispatch) + is PagingState.Error.Custom -> effectsLauncher.launch(action, nextState, ::dispatch) + is PagingState.Error.Exception -> effectsLauncher.launch(action, nextState, ::dispatch) + is PagingState.Loading -> effectsLauncher.launch(action, nextState, ::dispatch) + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealInjector.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealInjector.kt new file mode 100644 index 000000000..22e01b5ba --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealInjector.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.paging.core.impl + +internal class RealInjector : Injector { + var instance: T? = null + + override fun inject(): T { + return instance!! + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealJobCoordinator.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealJobCoordinator.kt new file mode 100644 index 000000000..4b330161d --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealJobCoordinator.kt @@ -0,0 +1,46 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class RealJobCoordinator( + private val childScope: CoroutineScope +) : JobCoordinator { + private val jobs: MutableMap = mutableMapOf() + + override fun launch( + key: Any, + block: suspend CoroutineScope.() -> Unit, + ) { + cancel(key) + + val job = + childScope.launch { + block() + } + jobs[key] = job + + job.invokeOnCompletion { + job.cancel() + } + } + + override fun launchIfNotActive( + key: Any, + block: suspend CoroutineScope.() -> Unit, + ) { + if (jobs[key]?.isActive != true) { + launch(key, block) + } + } + + override fun cancel(key: Any) { + jobs[key]?.cancel() + jobs.remove(key) + } + + override fun cancelAll() { + jobs.keys.forEach { cancel(it) } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealMutablePagingBuffer.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealMutablePagingBuffer.kt new file mode 100644 index 000000000..d73194e9e --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealMutablePagingBuffer.kt @@ -0,0 +1,93 @@ +package org.mobilenativefoundation.paging.core.impl + +import org.mobilenativefoundation.paging.core.MutablePagingBuffer +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.paging.core.PagingSource + +/** + * A concrete implementation of [MutablePagingBuffer], a custom data structure for efficiently storing and retrieving paging data. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param A The type of custom actions that can be dispatched to modify the paging state. + * @property maxSize The maximum size of the buffer. + */ +class RealMutablePagingBuffer, K : Any, P : Any, D : Any, E : Any, A : Any>( + private val maxSize: Int +) : MutablePagingBuffer { + + private val buffer: Array?> = arrayOfNulls(maxSize) + private val paramsToIndex: MutableMap, Int> = mutableMapOf() + private val keyToIndex: MutableMap, Int> = mutableMapOf() + private var head = 0 + private var tail = 0 + private var size = 0 + + override fun get(params: PagingSource.LoadParams): PagingSource.LoadResult.Data? { + val index = paramsToIndex[params] + return if (index != null) buffer[index] else null + } + + + override fun get(key: PagingKey): PagingSource.LoadResult.Data? { + val index = keyToIndex[key] + return if (index != null) buffer[index] else null + } + + + override fun put(params: PagingSource.LoadParams, page: PagingSource.LoadResult.Data) { + // Check if the buffer is full + if (size == maxSize) { + // Get the index of the oldest entry in the buffer + val oldestIndex = head + // Find the params associated with the oldest entry + val oldestParams = paramsToIndex.entries.first { it.value == oldestIndex }.key + // Remove the oldest entry from the maps + paramsToIndex.remove(oldestParams) + keyToIndex.remove(oldestParams.key) + // Remove the oldest entry from the buffer + buffer[oldestIndex] = null + // Update the head index to point to the next entry + head = (head + 1) % maxSize + } + // Get the index to insert the new page + val index = tail + // Insert the new page at the tail index + buffer[index] = page + // Update the maps with the new entry + paramsToIndex[params] = index + keyToIndex[params.key] = index + // Update the tail index to point to the next empty slot + tail = (tail + 1) % maxSize + // Update the size of the buffer + size = minOf(size + 1, maxSize) + } + + override fun head(): PagingSource.LoadResult.Data? { + return buffer[head] + } + + override fun getAll(): List> { + val pages = mutableListOf>() + var index = head + var count = 0 + while (count < size) { + val page = buffer[index] + if (page != null) { + pages.add(page) + } + index = (index + 1) % maxSize + count++ + } + return pages + } + + override fun isEmpty(): Boolean = size == 0 + + override fun indexOf(key: PagingKey): Int { + return keyToIndex[key] ?: -1 + } +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealOptionalInjector.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealOptionalInjector.kt new file mode 100644 index 000000000..5d5d7c12f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealOptionalInjector.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.paging.core.impl + +class RealOptionalInjector : OptionalInjector { + var instance: T? = null + + override fun inject(): T? { + return instance + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealPager.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealPager.kt new file mode 100644 index 000000000..b566bbc2f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealPager.kt @@ -0,0 +1,30 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.paging.core.Pager +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingConfig +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.paging.core.PagingState + +class RealPager, K : Any, P : Any, D : Any, E : Any, A : Any>( + initialKey: PagingKey, + stateManager: StateManager, + pagingConfigInjector: Injector, + private val dispatcher: Dispatcher, +) : Pager { + + private val pagingConfig = lazy { pagingConfigInjector.inject() } + + init { + if (pagingConfig.value.prefetchDistance > 0) { + dispatcher.dispatch(PagingAction.Load(initialKey)) + } + } + + override val state: StateFlow> = stateManager.state + + override fun dispatch(action: PagingAction.User) { + dispatcher.dispatch(action) + } +} diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealQueueManager.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealQueueManager.kt new file mode 100644 index 000000000..6feef8528 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RealQueueManager.kt @@ -0,0 +1,59 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.paging.core.FetchingStrategy +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingBuffer +import org.mobilenativefoundation.paging.core.PagingConfig +import org.mobilenativefoundation.paging.core.PagingKey + +class RealQueueManager, K : Any, P : Any, D : Any, E : Any, A : Any>( + pagingConfigInjector: Injector, + loggerInjector: OptionalInjector, + dispatcherInjector: Injector>, + private val fetchingStrategy: FetchingStrategy, + private val pagingBuffer: PagingBuffer, + private val anchorPosition: StateFlow>, + private val stateManager: StateManager, +) : QueueManager { + + private val logger = lazy { loggerInjector.inject() } + private val pagingConfig = lazy { pagingConfigInjector.inject() } + private val dispatcher = lazy { dispatcherInjector.inject() } + + private val queue: ArrayDeque> = ArrayDeque() + + override fun enqueue(key: PagingKey) { + logger.value?.log( + """ + Enqueueing: + Key: $key + """.trimIndent() + ) + + queue.addLast(key) + + processQueue() + } + + private fun processQueue() { + while (queue.isNotEmpty() && fetchingStrategy.shouldFetch( + anchorPosition = anchorPosition.value, + prefetchPosition = stateManager.state.value.prefetchPosition, + pagingConfig = pagingConfig.value, + pagingBuffer = pagingBuffer, + ) + ) { + val nextKey = queue.removeFirst() + + logger.value?.log( + """Dequeued: + Key: $nextKey + """.trimMargin(), + ) + + dispatcher.value.dispatch(PagingAction.Load(nextKey)) + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RetriesManager.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RetriesManager.kt new file mode 100644 index 000000000..483a56f53 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/RetriesManager.kt @@ -0,0 +1,31 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.paging.core.PagingSource + +class RetriesManager, K : Any, P : Any, D : Any> { + private val retries = mutableMapOf, Int>() + + private val mutexForRetries = Mutex() + + suspend fun resetRetriesFor(params: PagingSource.LoadParams) { + mutexForRetries.withLock { retries[params] = 0 } + } + + suspend fun getRetriesFor(params: PagingSource.LoadParams): Int { + val count = mutexForRetries.withLock { + retries[params] ?: 0 + } + + return count + } + + suspend fun incrementRetriesFor(params: PagingSource.LoadParams) { + mutexForRetries.withLock { + val prevCount = retries[params] ?: 0 + val nextCount = prevCount + 1 + retries[params] = nextCount + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/StateManager.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/StateManager.kt new file mode 100644 index 000000000..82d2c4d4e --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/StateManager.kt @@ -0,0 +1,50 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.mobilenativefoundation.paging.core.Logger +import org.mobilenativefoundation.paging.core.PagingState + +/** + * A class for managing the state of the paging process. + * + * @param Id The type of the unique identifier for each item in the paged data. + * @param K The type of the key used for paging. + * @param P The type of the parameters associated with each page of data. + * @param D The type of the data items. + * @param E The type of errors that can occur during the paging process. + * @param initialState The initial [PagingState]. + * @param loggerInjector The [OptionalInjector] for providing a [Logger] instance. + */ +class StateManager, K : Any, P : Any, D : Any, E : Any>( + initialState: PagingState, + loggerInjector: OptionalInjector +) { + + private val logger = lazy { loggerInjector.inject() } + + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + /** + * Updates the state with the specified [PagingState]. + * + * @param nextState The next [PagingState] to update the state with. + */ + fun update(nextState: PagingState) { + + log(nextState) + + _state.value = nextState + } + + private fun log(nextState: PagingState) { + logger.value?.log( + """ + Updating state: + Previous state: ${_state.value} + Next state: $nextState + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/StorePagingSourceStreamProvider.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/StorePagingSourceStreamProvider.kt new file mode 100644 index 000000000..4f0deaf04 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/impl/StorePagingSourceStreamProvider.kt @@ -0,0 +1,78 @@ +package org.mobilenativefoundation.paging.core.impl + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.paging.core.PagingData +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingSourceStreamProvider +import org.mobilenativefoundation.paging.core.StorePagingSourceKeyFactory +import org.mobilenativefoundation.store.store5.StoreReadResponse + +class StorePagingSourceStreamProvider, K : Any, P : Any, D : Any, E : Any>( + private val createParentStream: (key: PagingKey) -> Flow>, + private val createChildStream: (key: PagingKey) -> Flow>>, + private val keyFactory: StorePagingSourceKeyFactory +) : PagingSourceStreamProvider { + private val pages: MutableMap, PagingSource.LoadResult.Data> = mutableMapOf() + private val mutexForPages = Mutex() + + override fun provide(params: PagingSource.LoadParams): Flow> = + createParentStream(params.key).map { result -> + when (result) { + is PagingSource.LoadResult.Data -> { + mutexForPages.withLock { + pages[params.key] = result + } + + var data = result + + result.collection.items.forEach { child -> + val childKey = keyFactory.createKeyFor(child) + initAndCollectChildStream(child, childKey, params.key) { updatedData -> data = updatedData } + } + + data + } + + is PagingSource.LoadResult.Error -> result + } + } + + private fun initAndCollectChildStream( + data: PagingData.Single, + key: PagingKey, + parentKey: PagingKey, + emit: (updatedData: PagingSource.LoadResult.Data) -> Unit + ) { + createChildStream(key).distinctUntilChanged().onEach { response -> + + if (response is StoreReadResponse.Data) { + val updatedValue = response.value + + if (updatedValue is PagingData.Single) { + mutexForPages.withLock { + pages[parentKey]!!.let { currentData -> + val updatedItems = currentData.collection.items.toMutableList() + val indexOfChild = updatedItems.indexOfFirst { it.id == data.id } + val child = updatedItems[indexOfChild] + if (child != updatedValue) { + updatedItems[indexOfChild] = updatedValue + + val updatedPage = currentData.copy(collection = currentData.collection.copy(items = updatedItems)) + + pages[parentKey] = updatedPage + + emit(updatedPage) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/mutablePagingBufferOf.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/mutablePagingBufferOf.kt new file mode 100644 index 000000000..05d88fd64 --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/mutablePagingBufferOf.kt @@ -0,0 +1,7 @@ +package org.mobilenativefoundation.paging.core + +import org.mobilenativefoundation.paging.core.impl.RealMutablePagingBuffer + +inline fun , K : Any, P : Any, D : Any, E : Any, A : Any> mutablePagingBufferOf(maxSize: Int): MutablePagingBuffer { + return RealMutablePagingBuffer(maxSize) +} \ No newline at end of file diff --git a/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/store.kt b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/store.kt new file mode 100644 index 000000000..cf6dd837f --- /dev/null +++ b/paging/core/src/commonMain/kotlin/org/mobilenativefoundation/paging/core/store.kt @@ -0,0 +1,55 @@ +package org.mobilenativefoundation.paging.core + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull +import org.mobilenativefoundation.paging.core.impl.StorePagingSourceStreamProvider +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse + +@OptIn(ExperimentalStoreApi::class) +fun , K : Any, P : Any, D : Any, E : Any, A : Any> MutableStore, PagingData>.pagingSourceStreamProvider( + keyFactory: StorePagingSourceKeyFactory +): PagingSourceStreamProvider { + + fun createParentStream(key: PagingKey) = paged(key) + + fun createChildStream(key: PagingKey) = stream(StoreReadRequest.fresh(key)) + + return StorePagingSourceStreamProvider(::createParentStream, ::createChildStream, keyFactory) +} + +fun , K : Any, P : Any, D : Any, E : Any, A : Any> Store, PagingData>.pagingSourceStreamProvider( + keyFactory: StorePagingSourceKeyFactory +): PagingSourceStreamProvider { + + fun createParentStream(key: PagingKey) = paged(key) + + fun createChildStream(key: PagingKey) = stream(StoreReadRequest.fresh(key)) + + return StorePagingSourceStreamProvider(::createParentStream, ::createChildStream, keyFactory) +} + +@Suppress("UNCHECKED_CAST") +private fun , K : Any, P : Any, D : Any, E : Any> handleStoreReadResponse(response: StoreReadResponse>) = when (response) { + is StoreReadResponse.Data -> PagingSource.LoadResult.Data(response.value as PagingData.Collection) + is StoreReadResponse.Error.Exception -> PagingSource.LoadResult.Error.Exception(response.error) + is StoreReadResponse.Error.Message -> PagingSource.LoadResult.Error.Exception(Exception(response.message)) + + StoreReadResponse.Initial, + is StoreReadResponse.Loading, + is StoreReadResponse.NoNewData -> null + + is StoreReadResponse.Error.Custom<*> -> PagingSource.LoadResult.Error.Custom(response.error as E) +} + +@OptIn(ExperimentalStoreApi::class) +fun , K : Any, P : Any, D : Any, E : Any> MutableStore, PagingData>.paged( + key: PagingKey +): Flow> = stream(StoreReadRequest.fresh(key)).mapNotNull { response -> handleStoreReadResponse(response) } + +fun , K : Any, P : Any, D : Any, E : Any> Store, PagingData>.paged( + key: PagingKey +): Flow> = stream(StoreReadRequest.cached(key, refresh = false)).mapNotNull { response -> handleStoreReadResponse(response) } \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/RealPagerTest.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/RealPagerTest.kt new file mode 100644 index 000000000..bd2b545ee --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/RealPagerTest.kt @@ -0,0 +1,469 @@ +package org.mobilenativefoundation.paging.core + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.paging.core.PagingConfig.InsertionStrategy +import org.mobilenativefoundation.paging.core.utils.timeline.A +import org.mobilenativefoundation.paging.core.utils.timeline.AuthMiddleware +import org.mobilenativefoundation.paging.core.utils.timeline.Backend +import org.mobilenativefoundation.paging.core.utils.timeline.CK +import org.mobilenativefoundation.paging.core.utils.timeline.D +import org.mobilenativefoundation.paging.core.utils.timeline.E +import org.mobilenativefoundation.paging.core.utils.timeline.ErrorLoggingEffect +import org.mobilenativefoundation.paging.core.utils.timeline.Id +import org.mobilenativefoundation.paging.core.utils.timeline.K +import org.mobilenativefoundation.paging.core.utils.timeline.P +import org.mobilenativefoundation.paging.core.utils.timeline.PD +import org.mobilenativefoundation.paging.core.utils.timeline.PK +import org.mobilenativefoundation.paging.core.utils.timeline.SD +import org.mobilenativefoundation.paging.core.utils.timeline.TimelineAction +import org.mobilenativefoundation.paging.core.utils.timeline.TimelineActionReducer +import org.mobilenativefoundation.paging.core.utils.timeline.TimelineError +import org.mobilenativefoundation.paging.core.utils.timeline.TimelineKeyParams +import org.mobilenativefoundation.paging.core.utils.timeline.TimelineStoreFactory +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("TestFunctionName") +@OptIn(ExperimentalStoreApi::class) +class RealPagerTest { + private val testScope = TestScope() + + private lateinit var backend: Backend + private lateinit var timelineStoreFactory: TimelineStoreFactory + private lateinit var timelineStore: MutableStore + + @BeforeTest + fun setup() { + backend = Backend() + timelineStoreFactory = TimelineStoreFactory(backend.feedService, backend.postService) + timelineStore = timelineStoreFactory.create() + } + + + private fun TestScope.StandardTestPagerBuilder( + initialKey: PK, + anchorPosition: StateFlow, + pagingConfig: PagingConfig = PagingConfig(10, prefetchDistance = 50, insertionStrategy = InsertionStrategy.APPEND), + maxRetries: Int = 3, + errorHandlingStrategy: ErrorHandlingStrategy = ErrorHandlingStrategy.RetryLast(maxRetries), + timelineActionReducer: TimelineActionReducer? = null, + middleware: List> = emptyList(), + ) = PagerBuilder( + scope = this, + initialKey = initialKey, + initialState = PagingState.Initial(initialKey, null), + anchorPosition = anchorPosition + ) + .pagingConfig(pagingConfig) + + .mutableStorePagingSource(timelineStore) { + StorePagingSourceKeyFactory { + PagingKey(it.id, TimelineKeyParams.Single()) + } + } + + .defaultReducer { + errorHandlingStrategy(errorHandlingStrategy) + + timelineActionReducer?.let { + customActionReducer(it) + } + } + + .apply { + middleware.forEach { + this.middleware(it) + } + } + + .defaultLogger() + + private fun TestScope.StandardTestPager( + initialKey: PK, + anchorPosition: StateFlow, + pagingConfig: PagingConfig = PagingConfig(10, prefetchDistance = 50, insertionStrategy = InsertionStrategy.APPEND), + maxRetries: Int = 3, + errorHandlingStrategy: ErrorHandlingStrategy = ErrorHandlingStrategy.RetryLast(maxRetries), + timelineActionReducer: TimelineActionReducer? = null, + middleware: List> = emptyList(), + ) = StandardTestPagerBuilder(initialKey, anchorPosition, pagingConfig, maxRetries, errorHandlingStrategy, timelineActionReducer, middleware).build() + + + private suspend fun TurbineTestContext>.verifyPrefetching( + pageSize: Int, + prefetchDistance: Int + ) { + fun checkRange(data: List) { + data.forEachIndexed { index, item -> + val id = index + 1 + assertEquals(id, item.id) + } + } + + val initial = awaitItem() + assertIs>(initial) + + if (prefetchDistance > 0) { + val loading = awaitItem() + assertIs>(loading) + + val idle = awaitItem() + assertIs>(idle) + checkRange(idle.data) + assertEquals(pageSize, idle.data.size) + } + + var currentPage = 2 + var expectedDataSize = pageSize + + while (expectedDataSize < prefetchDistance) { + val loadingMore = awaitItem() + assertIs>(loadingMore) + + val idle = awaitItem() + assertIs>(idle) + checkRange(idle.data) + expectedDataSize += pageSize + assertEquals(expectedDataSize, idle.data.size) + + currentPage++ + } + } + + @Test + fun testPrefetchingWhenPrefetchDistanceIsGreaterThan0() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 50 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + val pager = StandardTestPager(initialKey, anchorPosition, pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND)) + + val state = pager.state + + state.test { + verifyPrefetching(pageSize, prefetchDistance) + + val headers = backend.getHeadersFor(initialKey) + assertEquals(0, headers.keys.size) + + expectNoEvents() + } + } + + @Test + fun testPrefetchingWhenPrefetchDistanceEquals0() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + val pager = StandardTestPager(initialKey, anchorPosition, pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND)) + + val state = pager.state + + state.test { + verifyPrefetching(pageSize, prefetchDistance) + + val headers = backend.getHeadersFor(initialKey) + assertEquals(0, headers.keys.size) + + expectNoEvents() + } + } + + @Test + fun testUserLoadWhenPrefetchDistanceEquals0() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + val pager = StandardTestPager(initialKey, anchorPosition, pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND)) + + val state = pager.state + + state.test { + verifyPrefetching(pageSize, prefetchDistance) + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading = awaitItem() + assertIs>(loading) + + val idle = awaitItem() + assertIs>(idle) + assertEquals(pageSize, idle.data.size) + + val headers = backend.getHeadersFor(initialKey) + assertEquals(0, headers.keys.size) + + expectNoEvents() + } + } + + @Test + fun testErrorHandlingStrategyRetryLast() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + val maxRetries = 3 + + val message = "Failed to load data" + val throwable = Throwable(message) + backend.failWith(throwable) + + val pager = StandardTestPager(initialKey, anchorPosition, pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND), maxRetries = maxRetries) + + val state = pager.state + + state.test { + val initial = awaitItem() + assertIs>(initial) + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading = awaitItem() + assertIs>(loading) + + val error = awaitItem() + assertIs>(error) + assertEquals(throwable, error.error) + + val retryCount = backend.getRetryCountFor(initialKey) + assertEquals(maxRetries, retryCount) + + val headers = backend.getHeadersFor(initialKey) + assertEquals(0, headers.keys.size) + + expectNoEvents() + } + } + + @Test + fun testErrorHandlingStrategyPassThrough() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + + + val message = "Failed to load data" + val throwable = Throwable(message) + backend.failWith(throwable) + + val pager = StandardTestPager( + initialKey, + anchorPosition, + pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND), + errorHandlingStrategy = ErrorHandlingStrategy.PassThrough + ) + + val state = pager.state + + state.test { + val initial = awaitItem() + assertIs>(initial) + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading = awaitItem() + assertIs>(loading) + + val error = awaitItem() + assertIs>(error) + assertEquals(throwable, error.error) + val retryCount = backend.getRetryCountFor(initialKey) + assertEquals(0, retryCount) + + val headers = backend.getHeadersFor(initialKey) + assertEquals(0, headers.keys.size) + + expectNoEvents() + } + } + + @Test + fun testCustomActionReducerModifiesState() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + + val pager = StandardTestPager( + initialKey, + anchorPosition, + pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND), + errorHandlingStrategy = ErrorHandlingStrategy.PassThrough, + timelineActionReducer = TimelineActionReducer() + ) + + val state = pager.state + + state.test { + val initial = awaitItem() + assertIs>(initial) + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading = awaitItem() + assertIs>(loading) + + val idle = awaitItem() + assertIs>(idle) + assertEquals(pageSize, idle.data.size) + + pager.dispatch(PagingAction.User.Custom(TimelineAction.ClearData)) + + val modifiedIdle = awaitItem() + assertIs>(modifiedIdle) + assertTrue(modifiedIdle.data.isEmpty()) + + val headers = backend.getHeadersFor(initialKey) + assertEquals(0, headers.keys.size) + + expectNoEvents() + } + } + + @Test + fun testMiddlewareInterceptsAndModifiesActions() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + + val authToken = "Bearer token123" + val authTokenProvider = { authToken } + val authMiddleware = AuthMiddleware(authTokenProvider) + + val pager = StandardTestPager( + initialKey, + anchorPosition, + pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND), + errorHandlingStrategy = ErrorHandlingStrategy.PassThrough, + timelineActionReducer = TimelineActionReducer(), + middleware = listOf(authMiddleware) + ) + + val state = pager.state + + state.test { + val initial = awaitItem() + assertIs>(initial) + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading = awaitItem() + assertIs>(loading) + + val idle = awaitItem() + assertIs>(idle) + assertEquals(pageSize, idle.data.size) + + val headers = backend.getHeadersFor(initialKey) + + assertEquals(1, headers.keys.size) + assertEquals("auth", headers.keys.first()) + assertEquals(authToken, headers.values.first()) + + expectNoEvents() + } + + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testEffectsAreLaunchedAfterReducingState() = testScope.runTest { + val pageSize = 10 + val prefetchDistance = 0 + val initialKey: CK = PagingKey(0, TimelineKeyParams.Collection(pageSize)) + val anchorPosition = MutableStateFlow(initialKey) + + val authToken = "Bearer token123" + val authTokenProvider = { authToken } + val authMiddleware = AuthMiddleware(authTokenProvider) + + val message = "Failed to load data" + val throwable = Throwable(message) + + val errorLoggingEffect = ErrorLoggingEffect { + when (it) { + is TimelineError.Exception -> backend.log("Exception", it.throwable.message ?: "") + } + } + + val pager = StandardTestPagerBuilder( + initialKey, + anchorPosition, + pagingConfig = PagingConfig(pageSize, prefetchDistance, InsertionStrategy.APPEND), + errorHandlingStrategy = ErrorHandlingStrategy.PassThrough, + timelineActionReducer = TimelineActionReducer(), + middleware = listOf(authMiddleware) + ).effect(PagingAction.UpdateError::class, PagingState.Error.Exception::class, errorLoggingEffect).build() + + val state = pager.state + + state.test { + val initial = awaitItem() + assertIs>(initial) + + backend.failWith(throwable) + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading = awaitItem() + assertIs>(loading) + + val error = awaitItem() + assertIs>(error) + + assertEquals(1, backend.getLogs().size) + assertEquals(message, backend.getLogs().first().message) + + backend.clearError() + + pager.dispatch(PagingAction.User.Load(initialKey)) + + val loading2 = awaitItem() + assertIs>(loading2) + + val idle = awaitItem() + assertIs>(idle) + assertEquals(pageSize, idle.data.size) + + assertEquals(1, backend.getLogs().size) + + val nextKey = idle.nextKey + assertNotNull(nextKey) + + backend.failWith(throwable) + + advanceUntilIdle() + + pager.dispatch(PagingAction.User.Load(nextKey)) + + val loadingMore = awaitItem() + assertIs>(loadingMore) + + val error2 = awaitItem() + assertIs>(error2) + + // The effect is configured to run for PagingState.Error only, not also PagingState.Data.ErrorLoadingMore + assertEquals(1, backend.getLogs().size) + + expectNoEvents() + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/AuthMiddleware.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/AuthMiddleware.kt new file mode 100644 index 000000000..42ff834fc --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/AuthMiddleware.kt @@ -0,0 +1,26 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import org.mobilenativefoundation.paging.core.Middleware +import org.mobilenativefoundation.paging.core.PagingAction + +class AuthMiddleware(private val authTokenProvider: () -> String) : Middleware { + private fun setAuthToken(headers: MutableMap) = headers.apply { + this["auth"] = authTokenProvider() + } + + override suspend fun apply(action: PagingAction, next: suspend (PagingAction) -> Unit) { + when (action) { + is PagingAction.User.Load -> { + setAuthToken(action.key.params.headers) + next(action) + } + + is PagingAction.Load -> { + setAuthToken(action.key.params.headers) + next(action) + } + + else -> next(action) + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/Backend.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/Backend.kt new file mode 100644 index 000000000..fedbc23aa --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/Backend.kt @@ -0,0 +1,62 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import kotlinx.coroutines.flow.MutableStateFlow +import org.mobilenativefoundation.paging.core.PagingKey +import kotlin.math.max + +class Backend { + + private val posts = mutableMapOf() + private val error = MutableStateFlow(null) + private val tries: MutableMap = mutableMapOf() + private val logs = mutableListOf() + + private val headers: MutableMap> = mutableMapOf() + + init { + (1..200).map { TimelineData.Post(it, "Post $it") }.forEach { this.posts[PagingKey(it.id, TimelineKeyParams.Single())] = it } + } + + val feedService: FeedService = RealFeedService(posts.values.toList(), error, { key -> + if (key !in tries) { + tries[key] = 0 + } + + tries[key] = tries[key]!! + 1 + }, { key -> + if (key !in headers) { + headers[key] = key.params.headers + } + + val mergedHeaders = headers[key]!! + key.params.headers + + headers[key] = mergedHeaders.toMutableMap() + }) + + val postService: PostService = RealPostService(posts, error) + + fun failWith(error: Throwable) { + this.error.value = error + } + + fun clearError() { + this.error.value = null + } + + fun getRetryCountFor(key: CK): Int { + val tries = tries[key] ?: 0 + val retries = tries - 1 + return max(retries, 0) + } + + fun getHeadersFor(key: CK): Map { + val headers = this.headers[key] ?: mapOf() + return headers + } + + fun log(name: String, message: String) { + logs.add(Event(name, message)) + } + + fun getLogs() = logs +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/ErrorLoggingEffect.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/ErrorLoggingEffect.kt new file mode 100644 index 000000000..075f06e8d --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/ErrorLoggingEffect.kt @@ -0,0 +1,17 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import org.mobilenativefoundation.paging.core.Effect +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingSource +import org.mobilenativefoundation.paging.core.PagingState + +class ErrorLoggingEffect(private val log: (error: E) -> Unit) : Effect, PagingState.Error.Exception> { + override fun invoke(action: PagingAction.UpdateError, state: PagingState.Error.Exception, dispatch: (PagingAction) -> Unit) { + when (val error = action.error) { + is PagingSource.LoadResult.Error.Custom -> {} + is PagingSource.LoadResult.Error.Exception -> { + log(TimelineError.Exception(error.error)) + } + } + } +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/Event.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/Event.kt new file mode 100644 index 000000000..e004db185 --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/Event.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +data class Event( + val name: String, + val message: String +) \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/FeedService.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/FeedService.kt new file mode 100644 index 000000000..92f9ac720 --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/FeedService.kt @@ -0,0 +1,5 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +interface FeedService { + suspend fun get(key: CK): TimelineData.Feed +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/PostService.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/PostService.kt new file mode 100644 index 000000000..25b8c91d2 --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/PostService.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +interface PostService { + suspend fun get(key: SK): TimelineData.Post? + suspend fun update(key: SK, value: TimelineData.Post) +} diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/RealFeedService.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/RealFeedService.kt new file mode 100644 index 000000000..2c66fc90b --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/RealFeedService.kt @@ -0,0 +1,32 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.paging.core.PagingKey + +class RealFeedService( + private val posts: List, + private val error: StateFlow, + private val incrementTriesFor: (key: CK) -> Unit, + private val setHeaders: (key: CK) -> Unit +) : FeedService { + + override suspend fun get(key: CK): TimelineData.Feed { + setHeaders(key) + + error.value?.let { + incrementTriesFor(key) + throw it + } + + val start = key.key + val end = start + key.params.size + val posts = this.posts.subList(start, end) + + return TimelineData.Feed( + posts, + itemsBefore = start - 1, + itemsAfter = this.posts.size - end, + nextKey = PagingKey(end, key.params) + ) + } +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/RealPostService.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/RealPostService.kt new file mode 100644 index 000000000..b921ed42f --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/RealPostService.kt @@ -0,0 +1,21 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import kotlinx.coroutines.flow.StateFlow + +class RealPostService( + private val posts: MutableMap, + private val error: StateFlow +) : PostService { + override suspend fun get(key: SK): TimelineData.Post? { + error.value?.let { throw it } + + return posts[key] + } + + override suspend fun update(key: SK, value: TimelineData.Post) { + error.value?.let { throw it } + + posts[key] = value + } + +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/TimelineActionReducer.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/TimelineActionReducer.kt new file mode 100644 index 000000000..e7ea4bdcd --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/TimelineActionReducer.kt @@ -0,0 +1,26 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import org.mobilenativefoundation.paging.core.PagingAction +import org.mobilenativefoundation.paging.core.PagingState +import org.mobilenativefoundation.paging.core.UserCustomActionReducer + +class TimelineActionReducer : UserCustomActionReducer { + override fun reduce(action: PagingAction.User.Custom, state: PagingState): PagingState { + return when (action.action) { + TimelineAction.ClearData -> { + val nextState = when (state) { + is PagingState.Data.ErrorLoadingMore -> state.copy(data = emptyList()) + is PagingState.Data.Idle -> state.copy(data = emptyList()) + is PagingState.Data.LoadingMore -> state.copy(data = emptyList()) + is PagingState.Error.Custom, + is PagingState.Error.Exception, + is PagingState.Initial, + is PagingState.Loading -> state + } + + nextState + } + } + } + +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/TimelineStoreFactory.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/TimelineStoreFactory.kt new file mode 100644 index 000000000..69f46bd5e --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/TimelineStoreFactory.kt @@ -0,0 +1,85 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import org.mobilenativefoundation.paging.core.PagingData +import org.mobilenativefoundation.paging.core.PagingKey +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.Converter +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.StoreBuilder +import org.mobilenativefoundation.store.store5.Updater +import org.mobilenativefoundation.store.store5.UpdaterResult + + +@OptIn(ExperimentalStoreApi::class) +class TimelineStoreFactory( + private val feedService: FeedService, + private val postService: PostService, +) { + + private fun createFetcher(): Fetcher = Fetcher.of { key -> + + + when (val params = key.params) { + is TimelineKeyParams.Collection -> { + val ck = PagingKey(key.key, params) + val feed = feedService.get(ck) + PagingData.Collection( + items = feed.posts.map { post -> PagingData.Single(post.id, post) }, + itemsBefore = feed.itemsBefore, + itemsAfter = feed.itemsAfter, + prevKey = key, + nextKey = feed.nextKey + ) + } + + is TimelineKeyParams.Single -> { + val sk = PagingKey(key.key, params) + val post = postService.get(sk) + if (post == null) { + throw Throwable("Post is null") + } else { + PagingData.Single(post.id, post) + } + } + } + } + + private fun createConverter(): Converter = Converter.Builder() + .fromOutputToLocal { it } + .fromNetworkToLocal { it } + .build() + + private fun createUpdater(): Updater = Updater.by( + post = { key, value -> + when (val params = key.params) { + is TimelineKeyParams.Single -> { + if (value is PagingData.Single) { + val updatedValue = value.data + if (updatedValue is TimelineData.Post) { + val sk = PagingKey(key.key, params) + val response = postService.update(sk, updatedValue) + UpdaterResult.Success.Typed(response) + } else { + UpdaterResult.Error.Message("Updated value is the wrong type. Expected ${TimelineData.Post::class}, received ${updatedValue::class}") + } + } else { + UpdaterResult.Error.Message("Updated value is the wrong type. Expected ${PagingData.Single::class}, received ${value::class}") + } + } + + is TimelineKeyParams.Collection -> throw UnsupportedOperationException("Updating collections is not supported") + } + }, + ) + + fun create(): MutableStore = + StoreBuilder.from( + fetcher = createFetcher() + ).toMutableStoreBuilder( + converter = createConverter() + ).build( + updater = createUpdater(), + bookkeeper = null + ) +} \ No newline at end of file diff --git a/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/types.kt b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/types.kt new file mode 100644 index 000000000..29f51c9ae --- /dev/null +++ b/paging/core/src/commonTest/kotlin/org/mobilenativefoundation/paging/core/utils/timeline/types.kt @@ -0,0 +1,81 @@ +package org.mobilenativefoundation.paging.core.utils.timeline + +import org.mobilenativefoundation.paging.core.PagingData +import org.mobilenativefoundation.paging.core.PagingKey + + +typealias Id = Int +typealias K = Int +typealias P = TimelineKeyParams +typealias CP = TimelineKeyParams.Collection +typealias SP = TimelineKeyParams.Single +typealias PK = PagingKey +typealias SK = PagingKey +typealias CK = PagingKey +typealias D = TimelineData +typealias PD = PagingData +typealias CD = PagingData.Collection +typealias SD = PagingData.Single +typealias A = TimelineAction +typealias E = TimelineError + +sealed class TimelineError { + data class Exception(val throwable: Throwable) : TimelineError() +} + +sealed interface TimelineAction { + data object ClearData : TimelineAction +} + +sealed interface TimelineKeyParams { + val headers: MutableMap + + data class Single( + override val headers: MutableMap = mutableMapOf(), + ) : TimelineKeyParams + + data class Collection( + val size: Int, + val filter: List> = emptyList(), + val sort: Sort? = null, + override val headers: MutableMap = mutableMapOf() + ) : TimelineKeyParams +} + +sealed class TimelineData { + data class Post( + val id: Id, + val content: String + ) : TimelineData() + + data class Feed( + val posts: List, + val itemsBefore: Int, + val itemsAfter: Int, + val nextKey: PK? + ) : TimelineData() +} + +enum class KeyType { + SINGLE, + COLLECTION +} + + +/** + * An enum defining sorting options that can be applied during fetching. + */ +enum class Sort { + NEWEST, + OLDEST, + ALPHABETICAL, + REVERSE_ALPHABETICAL, +} + +/** + * Defines filters that can be applied during fetching. + */ +interface Filter { + operator fun invoke(items: List): List +} + diff --git a/paging/docs/design_doc.md b/paging/docs/design_doc.md deleted file mode 100644 index 493a95ffb..000000000 --- a/paging/docs/design_doc.md +++ /dev/null @@ -1,246 +0,0 @@ -# Technical Design Doc: Native Paging Support in Store5 - -## Context and Scope -Feature request: [MobileNativeFoundation/Store#250](https://github.com/MobileNativeFoundation/Store/issues/250) - -This proposal addresses the need for paging support in Store. This enhancement aims to provide a simple, efficient, and flexible way to handle complex operations on large datasets. - -## Goals and Non-Goals -### Goals -- Provide native support for page-based and cursor-based fetches, handling both single items and collections. -- Enable read and write operations within a paging store. -- Support complex loading and fetching operations such as sorting and filtering. -- Ensure thread safety and concurrency support. -- Layer on top of existing Store APIs: no breaking changes! -### Non-Goals -- Integration with Paging3. -- Providing a one-size-fits-all solution: our approach should be flexible to cater to different use cases. - -## The Actual Design - -### APIs -#### StoreKey -An interface that defines keys used by Store for data-fetching operations. Allows Store to load individual items and collections of items. Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. Includes options for sorting and filtering. - -```kotlin - interface StoreKey { - interface Single : StoreKey { - val id: Id - } - interface Collection : StoreKey { - val insertionStrategy: InsertionStrategy - interface Page : Collection { - val page: Int - val size: Int - val sort: Sort? - val filters: List>? - } - interface Cursor : Collection { - val cursor: Id? - val size: Int - val sort: Sort? - val filters: List>? - } - } - } -``` - -#### StoreData -An interface that defines items that can be uniquely identified. Every item that implements the `StoreData` interface must have a means of identification. This is useful in scenarios when data can be represented as singles or collections. - -```kotlin - interface StoreData { - interface Single : StoreData { - val id: Id - } - interface Collection> : StoreData { - val items: List - fun copyWith(items: List): Collection - fun insertItems(strategy: InsertionStrategy, items: List): Collection - } - } -``` - -#### KeyProvider -An interface to derive keys based on provided data. `StoreMultiCache` depends on `KeyProvider` to: - -1. Derive a single key for a collection item based on the collection’s key and that item’s value. -2. Insert a single item into the correct collection based on its key and value. - -```kotlin - interface KeyProvider> { - fun from(key: StoreKey.Collection, value: Single): StoreKey.Single - fun from(key: StoreKey.Single, value: Single): StoreKey.Collection - } -``` - -### Implementations - -#### StoreMultiCache -Thread-safe caching system with collection decomposition. Manages data with utility functions to get, invalidate, and add items to the cache. Depends on `StoreMultiCacheAccessor` for internal data management. Should be used instead of `MultiCache`. - -```kotlin - class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( - private val keyProvider: KeyProvider, - singlesCache: Cache, Single> = CacheBuilder, Single>().build(), - collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), - ): Cache -``` - -#### StoreMultiCacheAccessor -Thread-safe intermediate data manager for a caching system supporting list decomposition. Tracks keys for rapid data retrieval and modification. - -#### LaunchPagingStore -Main entry point for the paging mechanism. This will launch and manage a `StateFlow` that reflects the current state of the Store. - -```kotlin - fun , Output : StoreData> Store.launchPagingStore( - scope: CoroutineScope, - keys: Flow, - ): StateFlow> - - @OptIn(ExperimentalStoreApi::class) - fun , Output : StoreData> MutableStore.launchPagingStore( - scope: CoroutineScope, - keys: Flow, - ): StateFlow> -``` - -## Usage -### StoreKey Example -```kotlin - sealed class ExampleKey : StoreKey { - data class Cursor( - override val cursor: String?, - override val size: Int, - override val sort: StoreKey.Sort? = null, - override val filters: List>? = null, - override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND - ) : StoreKey.Collection.Cursor, ExampleKey() - - data class Single( - override val id: String - ) : StoreKey.Single, ExampleKey() - } -``` - -### StoreData Example -```kotlin - sealed class ExampleData : StoreData { - data class Single(val postId: String, val title: String) : StoreData.Single, ExampleData() { - override val id: String get() = postId - } - - data class Collection(val singles: List) : StoreData.Collection, ExampleData() { - override val items: List get() = singles - override fun copyWith(items: List): StoreData.Collection = copy(singles = items) - override fun insertItems(strategy: InsertionStrategy, items: List): StoreData.Collection { - - return when (strategy) { - InsertionStrategy.APPEND -> { - val updatedItems = items.toMutableList() - updatedItems.addAll(singles) - copyWith(items = updatedItems) - } - - InsertionStrategy.PREPEND -> { - val updatedItems = singles.toMutableList() - updatedItems.addAll(items) - copyWith(items = updatedItems) - } - } - } - } - } -``` - -### LaunchPagingStore Example -```kotlin - @OptIn(ExperimentalStoreApi::class) - class ExampleViewModel( - private val store: MutableStore, - private val coroutineScope: CoroutineScope = viewModelScope, - private val loadSize: Int = DEFAULT_LOAD_SIZE - ) : ViewModel() { - - private val keys = MutableStateFlow(ExampleKey.Cursor(null, loadSize)) - private val _loading = MutableStateFlow(false) - private val _error = MutableStateFlow(null) - - val stateFlow = store.launchPagingStore(coroutineScope, keys) - val loading: StateFlow = _loading.asStateFlow() - val error: StateFlow = _error.asStateFlow() - - init { - TODO("Observe loading and error states and perform any other necessary initializations") - } - - fun loadMore() { - if (_loading.value) return // Prevent loading more if already loading - _loading.value = true - - coroutineScope.launch { - try { - val currentKey = keys.value - val currentCursor = currentKey.cursor - val nextCursor = determineNextCursor(currentCursor) - val nextKey = currentKey.copy(cursor = nextCursor) - keys.value = nextKey - } catch (e: Throwable) { - _error.value = e - } finally { - _loading.value = false - } - } - } - - fun write(key: ExampleKey.Single, value: ExampleData.Single) { - coroutineScope.launch { - try { - store.write(StoreWriteRequest.of(key, value)) - } catch (e: Throwable) { - _error.value = e - } - } - } - - private fun determineNextCursor(cursor: String?): String? { - // Implementation based on specific use case - // Return the next cursor or null if there are no more items to load - TODO("Provide an implementation or handle accordingly") - } - - companion object { - private const val DEFAULT_LOAD_SIZE = 100 - } - } -``` - -## Degree of Constraint -- Data items must implement the `StoreData` interface, ensuring they can be uniquely identified. -- Keys for loading data must implement the `StoreKey` interface. - -## Deprecations -- MultiCache -- Identifiable - -## Alternatives Considered -### Tailored Solution for Paging -#### Direct integration with Paging3 -Paging3 doesn’t have built-in support for: -- Singles and collections -- Write operations -- Sorting and filtering operations - -### Custom `StoreKey` and `StoreData` Structures -#### Loose Typing -#### Annotations and Reflection -#### Functional Programming Approach - -## Cross-Cutting Concerns -- Will Paging3 extensions be a maintenance nightmare? -- Will these APIs be simpler than Paging3? - -## Future Directions -- Bindings for Paging3 (follow-up PR) -- Support for KMP Compose UI (follow-up PR) \ No newline at end of file diff --git a/paging/gradle.properties b/paging/gradle.properties deleted file mode 100644 index 75852bdb3..000000000 --- a/paging/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=paging5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/paging/src/androidMain/AndroidManifest.xml b/paging/src/androidMain/AndroidManifest.xml deleted file mode 100644 index 8072ee00d..000000000 --- a/paging/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt deleted file mode 100644 index 58138e5e5..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ /dev/null @@ -1,116 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.paging5 - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.core5.StoreData -import org.mobilenativefoundation.store.core5.StoreKey -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse - -private class StopProcessingException : Exception() - -/** - * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. - * @param scope A [CoroutineScope]. - * @param keys A flow of keys that dictate how the Store should be updated. - * @param stream A lambda that invokes [Store.stream]. - * @return A read-only [StateFlow] reflecting the state of the Store. - */ -@ExperimentalStoreApi -private fun , Output : StoreData> launchPagingStore( - scope: CoroutineScope, - keys: Flow, - stream: (key: Key) -> Flow>, -): StateFlow> { - val stateFlow = MutableStateFlow>(StoreReadResponse.Initial) - - scope.launch { - - try { - val firstKey = keys.first() - if (firstKey !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") - - stream(firstKey).collect { response -> - if (response is StoreReadResponse.Data) { - val joinedDataResponse = joinData(firstKey, stateFlow.value, response) - stateFlow.emit(joinedDataResponse) - } else { - stateFlow.emit(response) - } - - if (response is StoreReadResponse.Data || - response is StoreReadResponse.Error || - response is StoreReadResponse.NoNewData - ) { - throw StopProcessingException() - } - } - } catch (_: StopProcessingException) { - } - - keys.drop(1).collect { key -> - if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") - val firstDataResponse = stream(key).first { it.dataOrNull() != null } as StoreReadResponse.Data - val joinedDataResponse = joinData(key, stateFlow.value, firstDataResponse) - stateFlow.emit(joinedDataResponse) - } - } - - return stateFlow.asStateFlow() -} - -/** - * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [launchPagingStore]. - */ -@ExperimentalStoreApi -fun , Output : StoreData> Store.launchPagingStore( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - return launchPagingStore(scope, keys) { key -> - this.stream(StoreReadRequest.fresh(key)) - } -} - -/** - * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [launchPagingStore]. - */ -@ExperimentalStoreApi -fun , Output : StoreData> MutableStore.launchPagingStore( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - return launchPagingStore(scope, keys) { key -> - this.stream(StoreReadRequest.fresh(key)) - } -} - -@ExperimentalStoreApi -private fun , Output : StoreData> joinData( - key: Key, - prevResponse: StoreReadResponse, - currentResponse: StoreReadResponse.Data -): StoreReadResponse.Data { - val lastOutput = when (prevResponse) { - is StoreReadResponse.Data -> prevResponse.value as? StoreData.Collection> - else -> null - } - - val currentData = currentResponse.value as StoreData.Collection> - - val joinedOutput = (lastOutput?.insertItems(key.insertionStrategy, currentData.items) ?: currentData) as Output - return StoreReadResponse.Data(joinedOutput, currentResponse.origin) -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt deleted file mode 100644 index af2d0e036..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ /dev/null @@ -1,167 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import app.cash.turbine.test -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.paging5.util.FakePostApi -import org.mobilenativefoundation.store.paging5.util.FakePostDatabase -import org.mobilenativefoundation.store.paging5.util.PostApi -import org.mobilenativefoundation.store.paging5.util.PostData -import org.mobilenativefoundation.store.paging5.util.PostDatabase -import org.mobilenativefoundation.store.paging5.util.PostKey -import org.mobilenativefoundation.store.paging5.util.PostPutRequestResult -import org.mobilenativefoundation.store.paging5.util.PostStoreFactory -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@OptIn(ExperimentalStoreApi::class) -class LaunchPagingStoreTests { - private val testScope = TestScope() - - private val userId = "123" - private lateinit var api: PostApi - private lateinit var db: PostDatabase - private lateinit var store: MutableStore - - @BeforeTest - fun setup() { - api = FakePostApi() - db = FakePostDatabase(userId) - val factory = PostStoreFactory(api, db) - store = factory.create() - } - - @Test - fun transitionFromInitialToData() = testScope.runTest { - val key = PostKey.Cursor("1", 10) - val keys = flowOf(key) - val stateFlow = store.launchPagingStore(this, keys) - - stateFlow.test { - val state1 = awaitItem() - assertIs(state1) - val state2 = awaitItem() - assertIs(state2) - val state3 = awaitItem() - assertIs>(state3) - expectNoEvents() - } - } - - @Test - fun multipleValidKeysEmittedInSuccession() = testScope.runTest { - val key1 = PostKey.Cursor("1", 10) - val key2 = PostKey.Cursor("11", 10) - val keys = flowOf(key1, key2) - val stateFlow = store.launchPagingStore(this, keys) - - stateFlow.test { - val state1 = awaitItem() - assertIs(state1) - val state2 = awaitItem() - assertIs(state2) - val state3 = awaitItem() - assertIs>(state3) - assertEquals("1", state3.value.posts[0].postId) - - val state4 = awaitItem() - assertIs>(state4) - assertEquals("11", state4.value.posts[0].postId) - assertEquals("1", state4.value.posts[10].postId) - val data4 = state4.value - assertIs(data4) - assertEquals(20, data4.items.size) - expectNoEvents() - } - } - - @Test - fun sameKeyEmittedMultipleTimes() = testScope.runTest { - val key = PostKey.Cursor("1", 10) - val keys = flowOf(key, key) - val stateFlow = store.launchPagingStore(this, keys) - - stateFlow.test { - val state1 = awaitItem() - assertIs(state1) - val state2 = awaitItem() - assertIs(state2) - val state3 = awaitItem() - assertIs>(state3) - expectNoEvents() - } - } - - @Test - fun multipleKeysWithReadsAndWrites() = testScope.runTest { - val api = FakePostApi() - val db = FakePostDatabase(userId) - val factory = PostStoreFactory(api = api, db = db) - val store = factory.create() - - val key1 = PostKey.Cursor("1", 10) - val key2 = PostKey.Cursor("11", 10) - val keys = flowOf(key1, key2) - - val stateFlow = store.launchPagingStore(this, keys) - stateFlow.test { - val initialState = awaitItem() - assertIs(initialState) - val loadingState = awaitItem() - assertIs(loadingState) - val loadedState1 = awaitItem() - assertIs>(loadedState1) - val data1 = loadedState1.value - assertEquals(10, data1.posts.size) - val loadedState2 = awaitItem() - assertIs>(loadedState2) - val data2 = loadedState2.value - assertEquals(20, data2.posts.size) - } - - val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached) - assertEquals(StoreReadResponseOrigin.Cache, cached.origin) - val data = cached.requireData() - assertIs(data) - assertEquals(10, data.posts.size) - - val cached2 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached2) - assertEquals(StoreReadResponseOrigin.Cache, cached2.origin) - val data2 = cached2.requireData() - assertIs(data2) - assertEquals("2", data2.title) - - store.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) - - val cached3 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached3) - assertEquals(StoreReadResponseOrigin.Cache, cached3.origin) - val data3 = cached3.requireData() - assertIs(data3) - assertEquals("2-modified", data3.title) - - val cached4 = - store.stream(StoreReadRequest.cached(PostKey.Cursor("1", 10), refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached4) - assertEquals(StoreReadResponseOrigin.Cache, cached4.origin) - val data4 = cached4.requireData() - assertIs(data4) - assertEquals("2-modified", data4.posts[1].title) - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt deleted file mode 100644 index 7764cc65e..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -class FakePostApi : PostApi { - - private val posts = mutableMapOf() - private val postsList = mutableListOf() - - init { - (1..100).forEach { - val id = it.toString() - posts[id] = PostData.Post(id, id) - postsList.add(PostData.Post(id, id)) - } - } - - override suspend fun get(postId: String): PostGetRequestResult { - val post = posts[postId] - return if (post != null) { - PostGetRequestResult.Data(post) - } else { - PostGetRequestResult.Error.Message("Post $postId was not found") - } - } - - override suspend fun get(cursor: String?, size: Int): FeedGetRequestResult { - val firstIndexInclusive = postsList.indexOfFirst { it.postId == cursor } - val lastIndexExclusive = firstIndexInclusive + size - val posts = postsList.subList(firstIndexInclusive, lastIndexExclusive) - return FeedGetRequestResult.Data(PostData.Feed(posts = posts)) - } - - override suspend fun put(post: PostData.Post): PostPutRequestResult { - posts.put(post.id, post) - return PostPutRequestResult.Data(post) - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt deleted file mode 100644 index a126c9b3f..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -class FakePostDatabase(private val userId: String) : PostDatabase { - private val posts = mutableMapOf() - private val feeds = mutableMapOf() - override fun add(post: PostData.Post) { - posts[post.id] = post - - val nextFeed = feeds[userId]?.posts?.map { - if (it.postId == post.postId) { - post - } else { - it - } - } - - nextFeed?.let { - feeds[userId] = PostData.Feed(nextFeed) - } - } - - override fun add(feed: PostData.Feed) { - feeds[userId] = feed - } - - override fun findPostByPostId(postId: String): PostData.Post? { - return posts[postId] - } - - override fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? { - val feed = feeds[userId] - return feed - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt deleted file mode 100644 index e1d13e50e..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -sealed class FeedGetRequestResult { - - data class Data(val data: PostData.Feed) : FeedGetRequestResult() - sealed class Error : FeedGetRequestResult() { - data class Message(val error: String) : Error() - data class Exception(val error: kotlin.Exception) : Error() - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt deleted file mode 100644 index 90d87e601..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -interface PostApi { - suspend fun get(postId: String): PostGetRequestResult - suspend fun get(cursor: String?, size: Int): FeedGetRequestResult - suspend fun put(post: PostData.Post): PostPutRequestResult -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt deleted file mode 100644 index ad6b05d28..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -import org.mobilenativefoundation.store.core5.InsertionStrategy -import org.mobilenativefoundation.store.core5.StoreData - -sealed class PostData : StoreData { - data class Post(val postId: String, val title: String) : StoreData.Single, PostData() { - override val id: String get() = postId - } - - data class Feed(val posts: List) : StoreData.Collection, PostData() { - override val items: List get() = posts - override fun copyWith(items: List): StoreData.Collection = copy(posts = items) - override fun insertItems(strategy: InsertionStrategy, items: List): StoreData.Collection { - - return when (strategy) { - InsertionStrategy.APPEND -> { - val updatedItems = items.toMutableList() - updatedItems.addAll(posts) - copyWith(items = updatedItems) - } - - InsertionStrategy.PREPEND -> { - val updatedItems = posts.toMutableList() - updatedItems.addAll(items) - copyWith(items = updatedItems) - } - - InsertionStrategy.REPLACE -> { - copyWith(items = posts) - } - } - } - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt deleted file mode 100644 index d8ae595c9..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -interface PostDatabase { - fun add(post: PostData.Post) - fun add(feed: PostData.Feed) - fun findPostByPostId(postId: String): PostData.Post? - fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt deleted file mode 100644 index d481f661f..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -sealed class PostGetRequestResult { - - data class Data(val data: PostData.Post) : PostGetRequestResult() - sealed class Error : PostGetRequestResult() { - data class Message(val error: String) : Error() - data class Exception(val error: kotlin.Exception) : Error() - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt deleted file mode 100644 index 451c5e0b9..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -import org.mobilenativefoundation.store.core5.InsertionStrategy -import org.mobilenativefoundation.store.core5.StoreKey - -sealed class PostKey : StoreKey { - data class Cursor( - override val cursor: String?, - override val size: Int, - override val sort: StoreKey.Sort? = null, - override val filters: List>? = null, - override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND - ) : StoreKey.Collection.Cursor, PostKey() - - data class Single( - override val id: String - ) : StoreKey.Single, PostKey() -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt deleted file mode 100644 index fdf855dbb..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -sealed class PostPutRequestResult { - - data class Data(val data: PostData.Post) : PostPutRequestResult() - sealed class Error : PostPutRequestResult() { - data class Message(val error: String) : Error() - data class Exception(val error: kotlin.Exception) : Error() - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt deleted file mode 100644 index 8ed9b2011..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ /dev/null @@ -1,138 +0,0 @@ -@file:OptIn(ExperimentalStoreApi::class) - -package org.mobilenativefoundation.store.paging5.util - -import kotlinx.coroutines.flow.flow -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.StoreMultiCache -import org.mobilenativefoundation.store.core5.KeyProvider -import org.mobilenativefoundation.store.core5.StoreKey -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import kotlin.math.floor - -class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { - - private fun createFetcher(): Fetcher = Fetcher.of { key -> - when (key) { - is PostKey.Single -> { - when (val result = api.get(key.id)) { - is PostGetRequestResult.Data -> { - result.data - } - - is PostGetRequestResult.Error.Exception -> { - throw Throwable(result.error) - } - - is PostGetRequestResult.Error.Message -> { - throw Throwable(result.error) - } - } - } - - is PostKey.Cursor -> { - when (val result = api.get(key.cursor, key.size)) { - is FeedGetRequestResult.Data -> { - result.data - } - - is FeedGetRequestResult.Error.Exception -> { - throw Throwable(result.error) - } - - is FeedGetRequestResult.Error.Message -> { - throw Throwable(result.error) - } - } - } - } - } - - private fun createSourceOfTruth(): SourceOfTruth = SourceOfTruth.of( - reader = { key -> - flow { - when (key) { - is PostKey.Single -> { - val post = db.findPostByPostId(key.id) - emit(post) - } - - is PostKey.Cursor -> { - val feed = db.findFeedByUserId(key.cursor, key.size) - emit(feed) - } - } - } - }, - writer = { key, data -> - when { - key is PostKey.Single && data is PostData.Post -> { - db.add(data) - } - - key is PostKey.Cursor && data is PostData.Feed -> { - db.add(data) - } - } - } - ) - - private fun createConverter(): Converter = - Converter.Builder() - .fromNetworkToLocal { it } - .fromOutputToLocal { it } - .build() - - private fun createUpdater(): Updater = Updater.by( - post = { key, data -> - when { - key is PostKey.Single && data is PostData.Post -> { - when (val result = api.put(data)) { - is PostPutRequestResult.Data -> UpdaterResult.Success.Typed(result) - is PostPutRequestResult.Error.Exception -> UpdaterResult.Error.Exception(result.error) - is PostPutRequestResult.Error.Message -> UpdaterResult.Error.Message(result.error) - } - } - - else -> UpdaterResult.Error.Message("Unsupported: key: ${key::class}, data: ${data::class}") - } - } - ) - - private fun createPagingCacheKeyProvider(): KeyProvider = - object : KeyProvider { - override fun fromCollection( - key: StoreKey.Collection, - value: PostData.Post - ): StoreKey.Single { - return PostKey.Single(value.postId) - } - - override fun fromSingle(key: StoreKey.Single, value: PostData.Post): StoreKey.Collection { - val id = value.postId.toInt() - val cursor = (floor(id.toDouble() / 10) * 10) + 1 - return PostKey.Cursor(cursor.toInt().toString(), 10) - } - } - - private fun createMemoryCache(): Cache = - StoreMultiCache(createPagingCacheKeyProvider()) - - fun create(): MutableStore = StoreBuilder.from( - fetcher = createFetcher(), - sourceOfTruth = createSourceOfTruth(), - memoryCache = createMemoryCache() - ).toMutableStoreBuilder( - converter = createConverter() - ).build( - updater = createUpdater(), - bookkeeper = null - ) -} diff --git a/settings.gradle b/settings.gradle index b8969413e..0836308ba 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,5 +6,5 @@ include ':store' include ':cache' include ':multicast' include ':rx2' -include ':paging' +include ':paging:core' include ':core'