diff --git a/paging/core/build.gradle.kts b/paging/core/build.gradle.kts index 99311c469..6fc4d4610 100644 --- a/paging/core/build.gradle.kts +++ b/paging/core/build.gradle.kts @@ -35,6 +35,7 @@ kotlin { implementation(kotlin("test")) implementation(libs.turbine) implementation(libs.kotlinx.coroutines.test) + implementation(project(":store")) } } } 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 +} +