diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2edda6487..5187cf53d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ ktlintGradle = "10.2.1" jacocoGradlePlugin = "0.8.7" mavenPublishPlugin = "0.22.0" moleculeGradlePlugin = "1.2.1" +pagingCompose = "3.3.0-alpha02" pagingRuntime = "3.2.1" spotlessPluginGradle = "6.4.1" junit = "4.13.2" @@ -25,6 +26,7 @@ truth = "1.1.3" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" } androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } @@ -51,3 +53,4 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor junit = { group = "junit", name = "junit", version.ref = "junit" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } touchlab-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } +turbine = "app.cash.turbine:turbine:0.12.3" diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts index d4122006b..06a0bd090 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -12,8 +12,7 @@ plugins { } kotlin { - android() - jvm() + androidTarget() sourceSets { val commonMain by getting { @@ -25,6 +24,7 @@ kotlin { implementation(libs.molecule.runtime) implementation(compose.ui) implementation(compose.foundation) + implementation(compose.material) } @@ -33,6 +33,17 @@ kotlin { val androidMain by getting { dependencies { implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + } + } + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.turbine) + implementation(libs.kotlinx.coroutines.test) + implementation(compose.uiTestJUnit4) + implementation(compose.ui) } } } diff --git a/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt b/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt deleted file mode 100644 index ddfc15dc2..000000000 --- a/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingSource -import androidx.paging.PagingState -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.impl.extensions.get - - -/** - * Converts the given [Store] into a [PagingSource] suitable for use with Paging3. - * - * @param keyProvider Provides methods to determine refresh and next keys for pagination. - * - * @return A [PagingSource] which can be used with a [Pager]. - */ -inline fun , - reified Value : Identifiable.Single> - Store>.asPagingSource( - keyProvider: PaginationKeyProvider>, -): PagingSource { - return object : PagingSource() { - override fun getRefreshKey(state: PagingState): Key? = - keyProvider.determineRefreshKey(state) - - override suspend fun load(params: LoadParams): LoadResult { - return try { - val key = params.key ?: return LoadResult.Invalid() - val data = get(key) - - val items = data.items.mapNotNull { it as? Value } - if (items.size == data.items.size) { - LoadResult.Page(data = items, prevKey = key, nextKey = keyProvider.determineNextKey(key, data)) - } else { - LoadResult.Error(ClassCastException("Expected items of type PagingOutput")) - } - } catch (error: Exception) { - LoadResult.Error(error) - } - } - - } -} - -/** - * Interface to provide pagination keys for the [Pager]. - */ -interface PaginationKeyProvider, Value : Identifiable.Single, StoreOutput : Identifiable.Collection> { - fun determineRefreshKey(state: PagingState): Key? - fun determineNextKey(key: Key, output: StoreOutput): Key? -} - - -/** - * Creates a [Pager] backed by the given [Store]. - * - * @param config Configuration for the paging behavior. - * @param initialKey Initial key to be used when loading data for the first time. - * @param keyProvider Provides methods to determine refresh and next keys for pagination. - * - * @return A [Pager] which can be used to paginate through the data. - */ -inline fun , reified Value : Identifiable.Single> - Store>.pager( - config: PagingConfig, - initialKey: Key? = null, - keyProvider: PaginationKeyProvider> -): Pager { - return Pager( - config = config, - initialKey = initialKey, - pagingSourceFactory = { this.asPagingSource(keyProvider) } - ) -} - - -/** - * Creates a [Pager] backed by the given [Store]. - * - * @param config Configuration for the paging behavior. - * @param initialKey Initial key to be used when loading data for the first time. - * @param keyProvider Provides methods to determine refresh and next keys for pagination. - * - * @return A [Pager] which can be used to paginate through the data. - */ -inline fun , reified Value : Identifiable.Single> - Store>.pager( - config: PagingConfig, - initialKey: Key? = null, - keyProvider: PaginationKeyProvider> -): Pager = this.pager(config, initialKey, keyProvider) \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt new file mode 100644 index 000000000..55871e763 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt @@ -0,0 +1,35 @@ +package org.mobilenativefoundation.store.paging5 + + +/** + * An interface that defines items that can be uniquely identified. + * Every item that implements the [Identifiable] interface must have a means of identification. + * This is useful in scenarios when data can be represented as singles or collections. + */ + +interface Identifiable { + + /** + * Represents a single identifiable item. + */ + interface Single : Identifiable { + val id: Id + } + + /** + * Represents a collection of identifiable items. + */ + interface Collection> : Identifiable { + val items: List + + /** + * Returns a new collection with the updated items. + */ + fun copyWith(items: List): Collection + + /** + * Inserts items to the existing collection and returns the updated collection. + */ + fun insertItems(type: StoreKey.LoadType, items: List): Collection + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt new file mode 100644 index 000000000..cc64a442b --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt @@ -0,0 +1,65 @@ +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.launch +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store + + +/** + * 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 updateStoreState A lambda that defines how the Store's state should be updated based on the current state and a key. + * @return A read-only [StateFlow] reflecting the state of the Store. + */ +private fun , Output : Identifiable> initStoreStateFlow( + scope: CoroutineScope, + keys: Flow, + updateStoreState: suspend (currentState: StoreState, key: Key) -> StoreState +): StateFlow> { + val stateFlow = MutableStateFlow>(StoreState.Loading) + + scope.launch { + keys.collect { key -> + println("KEY = $key") + println("CURRENT STATE = ${stateFlow.value}") + val updatedState = updateStoreState(stateFlow.value, key) + stateFlow.emit(updatedState) + } + } + + return stateFlow.asStateFlow() +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [initStoreStateFlow]. + */ +fun , Output : Identifiable> Store.initStoreStateFlow( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return initStoreStateFlow(scope, keys) { currentState, key -> + this.updateStoreState(currentState, key) + } +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [initStoreStateFlow]. + */ +@OptIn(ExperimentalStoreApi::class) +fun , Output : Identifiable> MutableStore.initStoreStateFlow( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return initStoreStateFlow(scope, keys) { currentState, key -> + this.updateStoreState(currentState, key) + } +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt new file mode 100644 index 000000000..48f351748 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store.paging5 + +interface KeyProvider> { + fun from(key: StoreKey.Collection, value: Single): StoreKey.Single + fun from(key: StoreKey.Single, value: Single): StoreKey.Collection +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt new file mode 100644 index 000000000..f9737a264 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt @@ -0,0 +1,146 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.cache5.Cache + +/** + * A class that represents a caching system for pagination. + * Manages data with utility functions to get, invalidate, and add items to the cache. + * Depends on [PagingCacheAccessor] for internal data management. + * @see [Cache]. + */ +class PagingCache, StoreOutput : Identifiable, Collection : Identifiable.Collection, Single : Identifiable.Single>( + private val keyProvider: KeyProvider, +) : Cache { + + private val accessor = PagingCacheAccessor() + + private fun Key.castSingle() = this as StoreKey.Single + private fun Key.castCollection() = this as StoreKey.Collection + + private fun StoreKey.Collection.cast() = this as Key + private fun StoreKey.Single.cast() = this as Key + + override fun getIfPresent(key: Key): StoreOutput? { + return when (key) { + is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? StoreOutput + is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? StoreOutput + else -> { + throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } + } + + override fun getOrPut(key: Key, valueProducer: () -> StoreOutput): StoreOutput { + return when (key) { + is StoreKey.Single<*> -> { + val single = accessor.getSingle(key.castSingle()) as? StoreOutput + if (single != null) { + single + } else { + val producedSingle = valueProducer() + put(key, producedSingle) + producedSingle + } + } + + is StoreKey.Collection<*> -> { + val collection = accessor.getCollection(key.castCollection()) as? StoreOutput + if (collection != null) { + collection + } else { + val producedCollection = valueProducer() + put(key, producedCollection) + producedCollection + } + } + + else -> { + throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } + } + + override fun getAllPresent(keys: List<*>): Map { + val map = mutableMapOf() + keys.filterIsInstance>().forEach { key -> + when (key) { + is StoreKey.Collection -> { + val collection = accessor.getCollection(key) + collection?.let { map[key.cast()] = it as StoreOutput } + } + + is StoreKey.Single -> { + val single = accessor.getSingle(key) + single?.let { map[key.cast()] = it as StoreOutput } + } + } + } + + return map + } + + override fun invalidateAll(keys: List) { + keys.forEach { key -> invalidate(key) } + } + + override fun invalidate(key: Key) { + when (key) { + is StoreKey.Single<*> -> accessor.invalidateSingle(key.castSingle()) + is StoreKey.Collection<*> -> accessor.invalidateCollection(key.castCollection()) + } + } + + override fun putAll(map: Map) { + map.entries.forEach { (key, value) -> put(key, value) } + } + + override fun put(key: Key, value: StoreOutput) { + when (key) { + is StoreKey.Single<*> -> { + val single = value as Single + accessor.putSingle(key.castSingle(), single) + + val collectionKey = keyProvider.from(key.castSingle(), single) + val existingCollection = accessor.getCollection(collectionKey) + if (existingCollection != null) { + val updatedItems = existingCollection.items.toMutableList().map { + if (it.id == single.id) { + single + } else { + it + } + } + val updatedCollection = existingCollection.copyWith(items = updatedItems) as Collection + accessor.putCollection(collectionKey, updatedCollection) + } + } + + is StoreKey.Collection<*> -> { + val collection = value as Collection + accessor.putCollection(key.castCollection(), collection) + + collection.items.forEach { + val single = it as? Single + if (single != null) { + accessor.putSingle(keyProvider.from(key.castCollection(), single), single) + } + } + } + } + } + + override fun invalidateAll() { + accessor.invalidateAll() + } + + override fun size(): Long { + return accessor.size() + } + + companion object { + fun invalidKeyErrorMessage(key: Any) = + "Expected StoreKey.Single or StoreKey.Collection, but received ${key::class}" + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt new file mode 100644 index 000000000..83e6f67fb --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt @@ -0,0 +1,92 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.cache5.CacheBuilder + +/** + * Intermediate data manager for a caching system supporting pagination. + * Tracks keys for rapid data retrieval and modification. + */ +class PagingCacheAccessor, Single : Identifiable.Single> { + private val collections = CacheBuilder, Collection>().build() + private val singles = CacheBuilder, Single>().build() + private val keys = mutableSetOf>() + + + /** + * Retrieves a collection of items from the cache using the provided key. + */ + fun getCollection(key: StoreKey.Collection): Collection? = collections.getIfPresent(key) + + /** + * Retrieves an individual item from the cache using the provided key. + */ + fun getSingle(key: StoreKey.Single): Single? = singles.getIfPresent(key) + + /** + * Stores a collection of items in the cache and updates the key set. + */ + fun putCollection(key: StoreKey.Collection, collection: Collection) { + collections.put(key, collection) + keys.add(key) + } + + /** + * Stores an individual item in the cache and updates the key set. + */ + fun putSingle(key: StoreKey.Single, single: Single) { + singles.put(key, single) + keys.add(key) + } + + /** + * Removes all cache entries and clears the key set. + */ + fun invalidateAll() { + collections.invalidateAll() + singles.invalidateAll() + keys.clear() + } + + /** + * Removes an individual item from the cache and updates the key set. + */ + fun invalidateSingle(key: StoreKey.Single) { + singles.invalidate(key) + keys.remove(key) + } + + /** + * Removes a collection of items from the cache and updates the key set. + */ + fun invalidateCollection(key: StoreKey.Collection) { + collections.invalidate(key) + keys.remove(key) + } + + /** + * Calculates the total count of items in the cache. + * Includes individual items as well as items in collections. + */ + + fun size(): Long { + var count = 0L + for (key in keys) { + when (key) { + is StoreKey.Single -> { + val single = singles.getIfPresent(key) + if (single != null) { + count++ + } + } + + is StoreKey.Collection -> { + val collection = collections.getIfPresent(key) + if (collection != null) { + count += collection.items.size + } + } + } + } + return count + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt new file mode 100644 index 000000000..00d8d5f94 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt @@ -0,0 +1,66 @@ +package org.mobilenativefoundation.store.paging5 + +/** + * An interface that defines keys used by Store for data-fetching operations. + * Allows Store to fetch 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. + */ +interface StoreKey { + + /** + * Represents a key for fetching an individual item. + */ + interface Single : StoreKey { + val id: Id + } + + /** + * Represents a key for fetching collections of items. + */ + interface Collection : StoreKey { + val loadType: LoadType + + /** + * Represents a key for page-based fetching. + */ + interface Page : Collection { + val page: Int + val size: Int + val sort: Sort? + val filters: List>? + } + + /** + * Represents a key for cursor-based fetching. + */ + interface Cursor : Collection { + val cursor: Id? + val size: Int + val sort: Sort? + val filters: List>? + } + } + + /** + * 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 + } + + enum class LoadType { + APPEND, + PREPEND + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt new file mode 100644 index 000000000..9c321c490 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt @@ -0,0 +1,51 @@ +package org.mobilenativefoundation.store.paging5 + +/** + * An interface that defines various states of data-fetching operations. + */ +sealed interface StoreState> { + + /** + * Represents the initial state. + */ + data object Initial : StoreState + + /** + * Represents the loading state. + */ + data object Loading : StoreState + + + /** + * Represents successful fetch operations. + */ + sealed interface Loaded> : StoreState { + + /** + * Represents a successful fetch of an individual item. + */ + data class Single>(val data: Output) : Loaded + + /** + * Represents a successful fetch of a collection of items. + */ + data class Collection, CO : Identifiable.Collection>(val data: CO) : + Loaded + } + + /** + * Represents unsuccessful fetch operations. + */ + sealed interface Error : StoreState { + + /** + * Represents an unsuccessful fetch operation due to an exception. + */ + data class Exception(val error: CustomException) : Error + + /** + * Represents an unsuccessful fetch operation due to an error with a message. + */ + data class Message(val error: String) : Error + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt new file mode 100644 index 000000000..743cd4e54 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt @@ -0,0 +1,89 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.impl.extensions.fresh + + +typealias LoadedCollection = StoreState.Loaded.Collection, Identifiable.Collection>> + + +/** + * Updates the Store's state based on a provided key and a retrieval mechanism. + * @param currentState The current state of the Store. + * @param key The key that dictates how the state should be updated. + * @param get A lambda that defines how to retrieve data from the Store based on a key. + */ +private suspend fun , Output : Identifiable> updateStoreState( + currentState: StoreState, + key: Key, + get: suspend (key: Key) -> Output, +): StoreState { + return try { + if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + + val lastOutput = when (currentState) { + is StoreState.Loaded.Collection<*, *, *> -> { + val data = (currentState as LoadedCollection).data + println("DATA = $data") + data + } + + else -> { + println("NULL") + null + } + } + + val nextOutput = get(key) as Identifiable.Collection> + + val output = (lastOutput?.insertItems(key.loadType, nextOutput.items) ?: nextOutput) + + println("OUTPUT * = $lastOutput $output") + StoreState.Loaded.Collection(output) as StoreState + + } catch (error: Exception) { + StoreState.Error.Exception(error) + } +} + +/** + * Updates the [Store]'s state based on a provided key. + * @see [updateStoreState]. + */ +suspend fun , Output : Identifiable> Store.updateStoreState( + currentState: StoreState, + key: Key +): StoreState { + return updateStoreState( + currentState, + key + ) { + this.fresh(it) + } +} + +/** + * Updates the [MutableStore]'s state based on a provided key. + * @see [updateStoreState]. + */ +@OptIn(ExperimentalStoreApi::class) +suspend fun , Output : Identifiable> MutableStore.updateStoreState( + currentState: StoreState, + key: Key +): StoreState { + return updateStoreState( + currentState, + key + ) { + val output = this.fresh(it) + println("KEY = $key") + println("OUTPUT = $output") + println("CURRENT STATE = $currentState") + output + } +} + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt deleted file mode 100644 index 42ee7c7ec..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import androidx.compose.runtime.Composable -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.Store - - -sealed class PostPagingData: Identifiable { - data class Post(val postId: String, val title: String) : Identifiable.Single, PostPagingData() { - override val id: String get() = postId - } - - data class Feed(val posts: List) : Identifiable.Collection, PostPagingData() { - override val items: List> get() = posts - } -} - - -sealed class PostPagingKey: StoreKey { - data class Key( - override val cursor: String, - override val size: Int, - override val sort: StoreKey.Sort?, - override val filters: List>? - ) : StoreKey.Collection.Cursor, PostPagingKey() - -} - - - -class PostPagingStoreFactory { - - private fun createFetcher(): Fetcher = TODO() - - fun create(): Store = TODO() -} - - -@Composable -fun FeedView(store: Store, Identifiable>) { - store.cursor( - key = PostPagingKey.Key("", 1, null, null), - initialContent = {}, - loadingContent = {}, - errorContent = {}, - onPrefetch = {_ -> PostPagingKey.Key("", 1, null, null)} - ) { - - } -} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt deleted file mode 100644 index 95a92416d..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt +++ /dev/null @@ -1,295 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.paging5 - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import app.cash.molecule.RecompositionMode -import app.cash.molecule.launchMolecule -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.impl.extensions.get - - -/** - * Interface defining items that can be identified. - * The identifiable items can either be standalone entities or collections of entities. - * - * This structure is particularly useful in scenarios where data can be represented - * both as individual units or as groups (collections) of units. For example, in a data fetch - * scenario, an API could return a single item or a list of items. - */ -sealed interface Identifiable { - - /** - * Represents a single identifiable item. - * Each single item must have a unique identifier, represented by the 'id' property. - */ - interface Single : Identifiable { - val id: Id - } - - /** - * Represents a collection of identifiable items. - * The collection is essentially a list of single items. - */ - interface Collection : Identifiable { - val items: List> - } -} - - -/** - * Interface defining keys used by the Store for data fetch operations. - * - * The StoreKey allows the Store to fetch either individual items or collections of items. - * Depending on the use-case, the fetch can be a simple ID-based fetch, a page-based fetch, - * or a cursor-based fetch. Sorting and filtering options are also provided. - */ -sealed interface StoreKey { - - /** - * Represents a key for fetching a single item based on its ID. - */ - interface Single : StoreKey { - val id: Id - } - - /** - * Represents a key for fetching collections (lists) of items. - */ - sealed interface Collection : StoreKey { - - /** - * Represents a key for page-based fetching. - * This includes the page number, size of the page, sorting option, and filters. - */ - interface Page : Collection { - val page: Int - val size: Int - val sort: Sort? - val filters: List>? - } - - /** - * Represents a key for cursor-based fetching. - * This includes a cursor string, size of the fetch, sorting option, and filters. - */ - interface Cursor : Collection { - val cursor: Id - val size: Int - val sort: Sort? - val filters: List>? - } - } - - /** - * Enum defining sorting options that can be applied during fetching. - */ - enum class Sort { - NEWEST, - OLDEST, - ALPHABETICAL, - REVERSE_ALPHABETICAL - } - - /** - * Class defining filters that can be applied during fetching. - * Each filter consists of a list of items and a block that defines the filtering criteria. - */ - interface Filter { - operator fun invoke(items: List): List - } -} - - -/** - * Interface defining different states of data fetch operations. - */ -sealed interface StoreState> { - data object Initial : StoreState - data object Loading : StoreState - - sealed interface Loaded> : StoreState { - data class Single>(val data: Output) : Loaded - - data class Collection>(val data: Output) : Loaded - } - - sealed interface Error : StoreState { - data class Exception(val error: CustomException) : Error - data class Message(val error: String) : Error - } -} - - -/** - * Extension function on the Store class to provide a stateful composable. - * It manages the state of a fetch operation for a given key. - * - * @param key The key based on which data will be fetched. - * @return A composable function that returns the current state of the fetch operation. - */ -fun , Output : Identifiable> Store.stateful(key: Key): @Composable () -> StoreState { - - @Composable - fun launch(): StoreState { - // Remember and manage the fetch operation's state. - var state by rememberSaveable { mutableStateOf>(StoreState.Loading) } - - LaunchedEffect(key) { - state = try { - key as StoreKey.Collection - val output = this@stateful.get(key) as Identifiable.Collection - StoreState.Loaded.Collection(output) as StoreState - - } catch (error: Exception) { - // Handle and store exceptions in the state. - StoreState.Error.Exception(error) - } - } - - return state - } - - return ::launch -} - - -/** - * A custom LazyColumn that supports prefetching. - * Detects when the user is close to the end of the displayed items and triggers a fetch for subsequent data. - */ -@Composable -fun > PrefetchingLazyColumn( - items: List, - threshold: Int = 3, - onPrefetch: (nextCursor: Id) -> Unit, - content: @Composable LazyItemScope.(T) -> Unit -) { - LazyColumn { - itemsIndexed(items) { index, item -> - if (index >= items.size - threshold) { - onPrefetch(items.last().id) - } - content(item) - } - } -} - - -/** - * A custom LazyColumn that supports prefetching. - * Detects when the user is close to the end of the displayed items and triggers a fetch for subsequent data. - */ -@Composable -fun > PrefetchingLazyColumn( - items: List, - threshold: Int = 3, - onPrefetch: () -> Unit, - content: @Composable LazyItemScope.(Value) -> Unit -) { - LazyColumn { - itemsIndexed(items) { index, item -> - if (index >= items.size - threshold) { - onPrefetch() - } - content(item) - } - } -} - - -/** - * Extension function on the Store class to launch a paging store. - * For each key in the provided StateFlow, it maps to a flow that emits the corresponding store state. - * - * @param keys A StateFlow containing keys based on which data will be fetched. - * @param scope The coroutine scope in which the operations will be launched. - * @return A StateFlow that emits the store state corresponding to each key. - */ -@OptIn(ExperimentalCoroutinesApi::class) -fun , Output : Identifiable> Store.launchPagingStore( - keys: StateFlow, - scope: CoroutineScope -): StateFlow> { - // For each key, create a flow that computes and emits the store state. - return keys.flatMapConcat { key -> - flow { - try { - // Launch a molecule to reactively compute the store state for the given key. - val storeState = scope.launchMolecule(mode = RecompositionMode.ContextClock) { - stateful(key) - } - // Emit the computed store state to the resulting flow. - emit(storeState.value.invoke()) - } catch (error: Exception) { - // Handle potential errors during state computation. - emit(StoreState.Error.Exception(error)) - } - } - }.stateIn( - scope, - SharingStarted.Lazily, - StoreState.Initial - ) // Convert the flow to a StateFlow with an initial state. -} - -/** - * A composable function designed for cursor-based pagination in the Store. - * It manages and displays data based on different states: initial, loading, loaded, and error. - * - * @param key The initial key for fetching data. - * @param initialContent Composable to display during the initial state. - * @param loadingContent Composable to display during the loading state. - * @param errorContent Composable to display when an error occurs. - * @param onPrefetch Function to determine the next key based on the current cursor. - * @param content Composable to display the fetched items. - */ -@Composable -inline fun > Store, Identifiable>.cursor( - key: StoreKey, - crossinline initialContent: @Composable () -> Unit = {}, - crossinline loadingContent: @Composable () -> Unit = {}, - crossinline errorContent: @Composable (error: StoreState.Error) -> Unit = {}, - crossinline onPrefetch: (nextCursor: Id) -> StoreKey, - crossinline content: @Composable (Value) -> Unit -) { - // MutableStateFlow to hold the current key for data fetch. - val keys = MutableStateFlow(key) - // Remembered coroutine scope for launching coroutines in Compose. - val scope = rememberCoroutineScope() - // Current state of the store fetched using the launchPagingStore function. - val state = launchPagingStore(keys, scope).collectAsState() - - // Render UI based on the current state. - when (val storeState = state.value) { - is StoreState.Error -> errorContent(storeState) - StoreState.Initial -> initialContent() - is StoreState.Loaded.Collection<*, *> -> { - val items = storeState.data.items as List> - - PrefetchingLazyColumn( - items = items, - onPrefetch = { nextCursor -> - val nextKey = onPrefetch(nextCursor) - keys.value = nextKey - } - ) { item -> - if (item is Value) { - content(item) - } else { - errorContent(StoreState.Error.Message("Unexpected item type: ${item::class.simpleName}")) - } - } - } - - is StoreState.Loaded.Single<*, *> -> errorContent(StoreState.Error.Message("Single item pagination not supported.")) - StoreState.Loading -> loadingContent() - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt new file mode 100644 index 000000000..208e5443f --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt @@ -0,0 +1,83 @@ +package org.mobilenativefoundation.store.paging5 + +import app.cash.turbine.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mobilenativefoundation.store.paging5.util.* +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class) +class InitStoreStateFlowTests { + private val testScope = TestScope() + + private val userId = "123" + private lateinit var api: PostApi + private lateinit var db: PostDatabase + private lateinit var store: MutableStore + + @Before + fun setup() { + api = FakePostApi() + db = FakePostDatabase(userId) + val factory = PostStoreFactory(api, db) + store = factory.create() + } + + @Test + fun `state transitions from Loading to Loaded Collection for valid Cursor key`() = testScope.runTest { + val key = PostKey.Cursor("1", 10) + val keys = flowOf(key) + val stateFlow = store.initStoreStateFlow(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs>(state2) + expectNoEvents() + } + } + + @Test + fun `state transitions appropriately for multiple valid keys emitted in succession`() = testScope.runTest { + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) + val stateFlow = store.initStoreStateFlow(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs>(state2) + + val state3 = awaitItem() + assertIs>(state3) + assertEquals(20, state3.data.items.size) + + expectNoEvents() + } + } + + @Test + fun `state remains consistent if the same key is emitted multiple times`() = testScope.runTest { + val key = PostKey.Cursor("1", 10) + val keys = flowOf(key, key) + val stateFlow = store.initStoreStateFlow(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs>(state2) + + expectNoEvents() + } + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt new file mode 100644 index 000000000..9622795b9 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt @@ -0,0 +1,70 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.paging5.util.* +import org.mobilenativefoundation.store.store5.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class) +class PagingTests { + private val testScope = TestScope() + private val userId = "123" + + @Test + fun happyPath() = 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) + + flowOf(key1, key2).collect { key -> + val state = store.updateStoreState(StoreState.Initial, key) + assertIs>(state) + assertEquals(10, state.data.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) + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..6751790f3 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt @@ -0,0 +1,39 @@ +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) { + println("HITTING 2 :)") + PostGetRequestResult.Data(post) + } else { + println("HITTING 2 :(") + 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) + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..6795acd48 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt @@ -0,0 +1,38 @@ +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) + println("UPDATED FEED $it") + } + } + + 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] + println("FEED RETURNING = $feed") + return feed + + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..8e6081a8d --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt @@ -0,0 +1,10 @@ +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() + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..fd733e301 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt @@ -0,0 +1,7 @@ +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 +} \ No newline at end of file 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 new file mode 100644 index 000000000..4a12bad09 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.paging5.Identifiable +import org.mobilenativefoundation.store.paging5.StoreKey + +sealed class PostData : Identifiable { + data class Post(val postId: String, val title: String) : Identifiable.Single, PostData() { + override val id: String get() = postId + } + + data class Feed(val posts: List) : Identifiable.Collection, PostData() { + override val items: List get() = posts + override fun copyWith(items: List): Identifiable.Collection = copy(posts = items) + override fun insertItems(type: StoreKey.LoadType, items: List): Identifiable.Collection { + + return when (type) { + StoreKey.LoadType.APPEND -> { + val updatedItems = items.toMutableList() + updatedItems.addAll(posts) + copyWith(items = updatedItems) + } + + StoreKey.LoadType.PREPEND -> { + val updatedItems = posts.toMutableList() + updatedItems.addAll(items) + copyWith(items = updatedItems) + } + } + } + } +} + + + 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 new file mode 100644 index 000000000..38b3d40b0 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt @@ -0,0 +1,8 @@ +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? +} \ No newline at end of file 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 new file mode 100644 index 000000000..f8f3e31ac --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt @@ -0,0 +1,10 @@ +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() + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..8feba6865 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -0,0 +1,18 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.paging5.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 loadType: StoreKey.LoadType = StoreKey.LoadType.PREPEND + ) : 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 new file mode 100644 index 000000000..8fd415099 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt @@ -0,0 +1,10 @@ +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() + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..51b368048 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -0,0 +1,134 @@ +@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.paging5.KeyProvider +import org.mobilenativefoundation.store.paging5.PagingCache +import org.mobilenativefoundation.store.paging5.StoreKey +import org.mobilenativefoundation.store.store5.* +import kotlin.math.floor + +typealias PostStore = MutableStore + +class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { + + private fun createFetcher(): Fetcher = Fetcher.of { key -> + println("HITTING IN FETCHER") + 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 -> + println("HITTING IN SOT") + 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 from(key: StoreKey.Collection, value: PostData.Post): StoreKey.Single { + return PostKey.Single(value.postId) + } + + override fun from(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 = + PagingCache(createPagingCacheKeyProvider()) + + fun create(): MutableStore = StoreBuilder.from( + fetcher = createFetcher(), + sourceOfTruth = createSourceOfTruth(), + memoryCache = createMemoryCache() + ).toMutableStoreBuilder( + converter = createConverter() + ).build( + updater = createUpdater(), + bookkeeper = null + ) +} \ No newline at end of file diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt index 29f41984d..d72a62859 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt @@ -2,12 +2,7 @@ package org.mobilenativefoundation.store.store5.impl.extensions import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.Updater +import org.mobilenativefoundation.store.store5.* import org.mobilenativefoundation.store.store5.impl.RealMutableStore import org.mobilenativefoundation.store.store5.impl.RealStore @@ -49,3 +44,18 @@ fun Store< bookkeeper = bookkeeper ) } + + +@OptIn(ExperimentalStoreApi::class) +suspend fun MutableStore.get(key: Key) = + stream(StoreReadRequest.cached(key, refresh = false)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() + +@OptIn(ExperimentalStoreApi::class) +suspend fun MutableStore.fresh(key: Key) = + stream(StoreReadRequest.fresh(key)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() \ No newline at end of file