diff --git a/build.gradle.kts b/build.gradle.kts index 4dd28cd..3b4ec15 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,14 +1,3 @@ -buildscript { - repositories { - gradlePluginPortal() - google() - mavenCentral() - } - dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") - } -} - plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.jvm) apply false diff --git a/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/DefaultStoreCoroutineExceptionHandler.kt b/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/DefaultStoreCoroutineExceptionHandler.kt new file mode 100644 index 0000000..4399290 --- /dev/null +++ b/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/DefaultStoreCoroutineExceptionHandler.kt @@ -0,0 +1,10 @@ +package io.github.ikarenkov.kombucha + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName + +fun DefaultStoreCoroutineExceptionHandler() = CoroutineExceptionHandler { context, throwable -> + val storeName = context[CoroutineName] + println("Unhandled error in Coroutine store named \"$storeName\".") + throwable.printStackTrace() +} \ No newline at end of file diff --git a/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/CoroutinesStore.kt b/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/CoroutinesStore.kt index 840e3c5..4618036 100644 --- a/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/CoroutinesStore.kt +++ b/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/CoroutinesStore.kt @@ -1,11 +1,10 @@ package io.github.ikarenkov.kombucha.store +import io.github.ikarenkov.kombucha.DefaultStoreCoroutineExceptionHandler import io.github.ikarenkov.kombucha.eff_handler.EffectHandler import io.github.ikarenkov.kombucha.reducer.Reducer import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,7 +15,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.EmptyCoroutineContext /** * Basic coroutines safe implementation of [Store]. State modification is consequential with locking using [Mutex]. @@ -34,10 +32,7 @@ open class CoroutinesStore( private val effectHandlers: List> = listOf(), initialState: Model, initialEffects: Set = setOf(), - coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> - println("Unhandled error in Coroutine store named \"$name\".") - throwable.printStackTrace() - } + coroutineExceptionHandler: CoroutineExceptionHandler = DefaultStoreCoroutineExceptionHandler() ) : Store { private val mutableState = MutableStateFlow(initialState) @@ -49,11 +44,7 @@ open class CoroutinesStore( private val isCanceled: Boolean get() = !coroutinesScope.isActive - open val coroutinesScope = CoroutineScope( - SupervisorJob() + - coroutineExceptionHandler + - (name?.let { CoroutineName(name) } ?: EmptyCoroutineContext) - ) + open val coroutinesScope = StoreScope(name, coroutineExceptionHandler) private val stateUpdateMutex = Mutex() diff --git a/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/StoreScope.kt b/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/StoreScope.kt index 60c717b..8915d29 100644 --- a/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/StoreScope.kt +++ b/kombucha/core/src/commonMain/kotlin/io/github/ikarenkov/kombucha/store/StoreScope.kt @@ -4,13 +4,16 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext fun StoreScope( name: String? = null, - coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, _ -> } + coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, _ -> }, + coroutineContext: CoroutineContext = EmptyCoroutineContext ) = CoroutineScope( SupervisorJob() + coroutineExceptionHandler + - (name?.let { CoroutineName(name) } ?: EmptyCoroutineContext) + (name?.let { CoroutineName(name) } ?: EmptyCoroutineContext) + + coroutineContext ) \ No newline at end of file diff --git a/kombucha/ui-adapter/README.md b/kombucha/ui-adapter/README.md new file mode 100644 index 0000000..1acbf7d --- /dev/null +++ b/kombucha/ui-adapter/README.md @@ -0,0 +1,41 @@ +# UI Adapter + +This module contains [UiStore](/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStore.kt) that provides functionality: + +1. Convert models to Ui Models +2. Cache ui effects when there is no subscribers and emit cached effects with a first subscription. It can be disable using + parameter `cacheUiEffects = false`. + +You can use [UiStoreBuilder](/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStoreBuilder.kt) and function `uiBuilder` for convenient usage +without declaring all 6 generics. [UiStoreBuilder] also provides some build in functions and you can easily extend it using extension fun. + +## Sample + +Take a look to the [sample.feature.ui](../../sample/features/ui) for detailed examples of usage. + +If your UiMsg and UiEff are subclasses of Msg and Eff, you can use following code for simple mapping only UiState + +```kotlin +val store: Store = ... +val uiStore = store.uiBuilder().using { state -> + UiState( + state.itemsIds.map { resources.getString(R.string.item_title, it) } + ) +} +``` + +Otherwise you can provide your own mappers for UiMsg -> Msg and for Eff -> UiEff + +```kotlin +store.uiBuilder().using( + uiMsgToMsgConverter = { it }, + uiStateConverter = { state -> + UiState( + state.itemsIds.map { resources.getString(R.string.item_title, it) } + ) + }, + uiEffConverter = { eff -> + eff as? Eff.Ext + } +) +``` \ No newline at end of file diff --git a/kombucha/ui-adapter/build.gradle.kts b/kombucha/ui-adapter/build.gradle.kts index 3c14e22..a726816 100644 --- a/kombucha/ui-adapter/build.gradle.kts +++ b/kombucha/ui-adapter/build.gradle.kts @@ -1,10 +1,23 @@ plugins { - alias(libs.plugins.kombucha.android.library) + alias(libs.plugins.kombucha.kmp.library) } -android.namespace = "io.github.ikarenkov.kombucha.ui_adapter" +tasks.withType { + useJUnitPlatform() +} -dependencies { - implementation(libs.kotlinx.coroutines.core) - implementation(projects.kombucha.core) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(projects.kombucha.core) + } + commonTest.dependencies { + implementation(libs.test.kotlin) + implementation(libs.test.coroutines) + } + jvmTest.dependencies { + implementation(libs.test.junit.jupiter) + } + } } \ No newline at end of file diff --git a/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/CacheWhenNoSubscribers.kt b/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/CacheWhenNoSubscribers.kt new file mode 100644 index 0000000..4b4e35a --- /dev/null +++ b/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/CacheWhenNoSubscribers.kt @@ -0,0 +1,24 @@ +package io.github.ikarenkov.kombucha.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * Turn cold flow to hot and caches values only if there are no subscribers. + */ +@ExperimentalCoroutinesApi +fun Flow.cacheWhenNoSubscribers(scope: CoroutineScope): Flow { + val cache = MutableSharedFlow(replay = Int.MAX_VALUE) + scope.launch { + collect { cache.emit(it) } + } + return cache.onEach { + if (cache.subscriptionCount.value != 0) { + cache.resetReplayCache() + } + } +} diff --git a/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStore.kt b/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStore.kt new file mode 100644 index 0000000..293e1b1 --- /dev/null +++ b/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStore.kt @@ -0,0 +1,68 @@ +package io.github.ikarenkov.kombucha.ui + +import io.github.ikarenkov.kombucha.DefaultStoreCoroutineExceptionHandler +import io.github.ikarenkov.kombucha.store.Store +import io.github.ikarenkov.kombucha.store.StoreScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn + +/** + * Wrapper for store, that allows handle basic UI scenarios: + * 1. Convert models to Ui Models + * 2. Cache ui effects when there is no subscribers and emit cached effects with a first subscription + */ +class UiStore( + private val store: Store, + private val uiMsgToMsgConverter: (UiMsg) -> Msg, + private val uiStateConverter: (State) -> UiState, + private val uiEffConverter: (Eff) -> UiEff?, + coroutineExceptionHandler: CoroutineExceptionHandler = DefaultStoreCoroutineExceptionHandler(), + uiDispatcher: CoroutineDispatcher = Dispatchers.Main, + cacheUiEffects: Boolean = true +) : Store { + + private val coroutineScope = StoreScope( + name = "UiStore for $store", + coroutineExceptionHandler = coroutineExceptionHandler, + coroutineContext = uiDispatcher + ) + + override val state: StateFlow = store.state + .map { uiStateConverter(it) } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Lazily, + initialValue = uiStateConverter(store.state.value) + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override val effects: Flow = + store.effects + .mapNotNull { uiEffConverter(it) } + .let { originalFlow -> + if (cacheUiEffects) { + originalFlow.cacheWhenNoSubscribers(coroutineScope) + } else { + originalFlow + } + } + + override fun accept(msg: UiMsg) { + store.accept(uiMsgToMsgConverter(msg)) + } + + override fun cancel() { + coroutineScope.cancel() + store.cancel() + } + +} \ No newline at end of file diff --git a/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStoreBuilder.kt b/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStoreBuilder.kt new file mode 100644 index 0000000..685a7e5 --- /dev/null +++ b/kombucha/ui-adapter/src/commonMain/kotlin/io/github/ikarenkov/kombucha/ui/UiStoreBuilder.kt @@ -0,0 +1,45 @@ +package io.github.ikarenkov.kombucha.ui + +import io.github.ikarenkov.kombucha.store.Store + +/** + * Builder that helps to avoid explicit declaration of generic types in [UiStore] and provides different builder functions to build [UiStore]. + * You can + */ +class UiStoreBuilder( + val store: Store +) { + + fun using( + uiMsgToMsgConverter: (UiMsg) -> Msg, + uiStateConverter: (State) -> UiState, + uiEffConverter: (Eff) -> UiEff?, + cacheUiEffects: Boolean = true, + ): UiStore = UiStore( + store = store, + uiMsgToMsgConverter = uiMsgToMsgConverter, + uiStateConverter = uiStateConverter, + uiEffConverter = uiEffConverter, + cacheUiEffects = cacheUiEffects + ) + + /** + * Implementation based on inheritance UiMsg from Msg and UiEff from Eff + */ + inline fun using( + cacheUiEffects: Boolean = true, + noinline uiStateConverter: (State) -> UiState, + ): UiStore = UiStore( + store = store, + uiMsgToMsgConverter = { it }, + uiStateConverter = uiStateConverter, + uiEffConverter = { it as? UiEff }, + cacheUiEffects = cacheUiEffects + ) + +} + +/** + * Dsl builder that helps to avoid full declaration of generic types using regular constructor of [UiStore]. + */ +fun Store.uiBuilder() = UiStoreBuilder(this) diff --git a/kombucha/ui-adapter/src/commonTest/kotlin/io/github/ikarenkov/kombucha/ui/CacheWhenNoSubscribersTest.kt b/kombucha/ui-adapter/src/commonTest/kotlin/io/github/ikarenkov/kombucha/ui/CacheWhenNoSubscribersTest.kt new file mode 100644 index 0000000..98dd6ac --- /dev/null +++ b/kombucha/ui-adapter/src/commonTest/kotlin/io/github/ikarenkov/kombucha/ui/CacheWhenNoSubscribersTest.kt @@ -0,0 +1,119 @@ +package io.github.ikarenkov.kombucha.ui + +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals + +class CacheWhenNoSubscribersTest { + + @Test + fun TestSeveralSubscriptions() { + val originalFlow = flow { + emit(1) + delay(100) + emit(2) + delay(100) + emit(3) + delay(1000) + emit(4) + emit(5) + } + + val coroutineScope = TestScope() + val cachedFlow = originalFlow.cacheWhenNoSubscribers(coroutineScope) + + val (job1, list1) = cachedFlow.collectToList(coroutineScope) + coroutineScope.advanceTimeBy(101) + assertEquals(listOf(1, 2), list1) + + val (job2, list2) = cachedFlow.collectToList(coroutineScope) + coroutineScope.advanceTimeBy(201) + + assertEquals(listOf(3), list2) + assertEquals(listOf(1, 2, 3), list1) + + // cancel all subscriptions - caching must start after it + job1.cancel() + job2.cancel() + coroutineScope.advanceTimeBy(2000) + + val (_, list3) = cachedFlow.collectToList(coroutineScope) + coroutineScope.advanceTimeBy(1000) + assertEquals(listOf(4, 5), list3) + } + + @Test + @JsName("test2") + fun `Test caching before first subscription`() { + val originalFlow = flow { + emit(1) + delay(100) + emit(2) + delay(100) + emit(3) + delay(1000) + emit(4) + emit(5) + } + + val coroutineScope = TestScope() + val cachedFlow = originalFlow.cacheWhenNoSubscribers(coroutineScope) + + coroutineScope.advanceTimeBy(201) + + val (_, list) = cachedFlow.collectToList(coroutineScope) + coroutineScope.advanceTimeBy(1) + assertEquals(listOf(1, 2, 3), list) + } + + @Test + @JsName("test3") + fun `When scope is canceled - no more events`() = runTest { + val originalFlow = flow { + emit(1) + delay(100) + emit(2) + delay(100) + emit(3) + delay(1000) + emit(4) + emit(5) + } + + val coroutineScope = TestScope() + val cachedFlow = originalFlow.cacheWhenNoSubscribers(coroutineScope) + + val (job1, list1) = cachedFlow.collectToList(coroutineScope) + coroutineScope.advanceTimeBy(101) + assertEquals(listOf(1, 2), list1) + + coroutineScope.cancel() + + coroutineScope.advanceTimeBy(2000) + assertEquals(listOf(1, 2), list1) + + val (job2, list2) = cachedFlow.collectToList(coroutineScope) + coroutineScope.advanceTimeBy(2000) + assertEquals(listOf(), list2) + } + + private fun Flow.collectToList(coroutineScope: TestScope): Pair> { + val list = mutableListOf() + val job = coroutineScope.launch { + collect { + list += it + } + } + return job to list + } + +} \ No newline at end of file diff --git a/kombucha/ui-adapter/src/main/kotlin/io/github/ikarenkov/kombucha/ui_adapter/UiStore.kt b/kombucha/ui-adapter/src/main/kotlin/io/github/ikarenkov/kombucha/ui_adapter/UiStore.kt deleted file mode 100644 index ab0b468..0000000 --- a/kombucha/ui-adapter/src/main/kotlin/io/github/ikarenkov/kombucha/ui_adapter/UiStore.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.github.ikarenkov.kombucha.ui_adapter - -import io.github.ikarenkov.kombucha.store.CoroutinesStore -import io.github.ikarenkov.kombucha.store.Store -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -class UiStore( - private val store: CoroutinesStore, - private val uiMsgConverter: (UiMsg) -> Msg, - private val uiStateConverter: (State) -> UiState, - private val uiEffConverter: (Eff) -> UiEff, -) : Store { - - override val state: StateFlow = store.state - .map { uiStateConverter(it) } - .stateIn( - scope = store.coroutinesScope, - started = SharingStarted.Lazily, - initialValue = uiStateConverter(store.state.value) - ) - override val effects: Flow = store.effects.map { uiEffConverter(it) } - - override fun accept(msg: UiMsg) { - store.accept(uiMsgConverter(msg)) - } - - override fun cancel() { - store.cancel() - } - -} \ No newline at end of file diff --git a/sample/app/build.gradle.kts b/sample/app/build.gradle.kts index 74c1e2e..ccbaf35 100644 --- a/sample/app/build.gradle.kts +++ b/sample/app/build.gradle.kts @@ -37,4 +37,5 @@ dependencies { implementation(projects.sample.features.game.impl) implementation(projects.sample.features.learnCompose.impl) implementation(projects.sample.features.shikimori) + implementation(projects.sample.features.ui) } \ No newline at end of file diff --git a/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/deps/UiSampleDepsImpl.kt b/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/deps/UiSampleDepsImpl.kt new file mode 100644 index 0000000..f71c2d2 --- /dev/null +++ b/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/deps/UiSampleDepsImpl.kt @@ -0,0 +1,14 @@ +package io.github.ikarenkov.kombucha.sample.deps + +import com.github.terrakok.modo.stack.forward +import io.github.ikarenkov.kombucha.sample.NavigationHolder +import io.github.ikarenkov.sample.ui.api.UiSampleDeps +import io.github.ikarenkov.sample.ui.api.uiSampleFeatureFacade + +class UiSampleDepsImpl( + private val navigationHolder: NavigationHolder, +) : UiSampleDeps { + override fun openDetailsScreen(id: String) { + navigationHolder.rootScreen?.screen?.forward(uiSampleFeatureFacade.api.detailsScreen(id)) + } +} \ No newline at end of file diff --git a/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/di/Modules.kt b/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/di/Modules.kt index 932eb76..d0c0a3f 100644 --- a/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/di/Modules.kt +++ b/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/di/Modules.kt @@ -8,7 +8,9 @@ import io.github.ikarenkov.kombucha.sample.kombucha.KombuchaStoreFactory import io.github.ikarenkov.sample.shikimori.api.ShikimoriDeps import org.koin.dsl.module import io.github.ikarenkov.kombucha.sample.counter.api.CounterDeps +import io.github.ikarenkov.kombucha.sample.deps.UiSampleDepsImpl import io.github.ikarenkov.kombucha.store.StoreFactory +import io.github.ikarenkov.sample.ui.api.UiSampleDeps fun appModule(context: Context) = module { single { context } @@ -18,4 +20,6 @@ fun appModule(context: Context) = module { single { CounterDepsImpl(get()) } single { ShikimoryDepsImpl() } single { get() } + single { UiSampleDepsImpl(get()) } + single { get() } } \ No newline at end of file diff --git a/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/root/KombuchaRootScreen.kt b/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/root/KombuchaRootScreen.kt index 08942e8..f82f439 100644 --- a/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/root/KombuchaRootScreen.kt +++ b/sample/app/src/main/kotlin/io/github/ikarenkov/kombucha/sample/root/KombuchaRootScreen.kt @@ -21,6 +21,7 @@ import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.forward +import io.github.ikarenkov.sample.ui.api.uiSampleFeatureFacade import kotlinx.parcelize.Parcelize @Parcelize @@ -48,6 +49,7 @@ private fun RootScreen(parent: StackScreen) { "Learn compose" to { learnComposeFeatureFacade.api.screen() }, "Game" to { gameFeatureFacade.api.createScreen() }, "Shikimori" to { shikimoriFeatureFacade.api.createScreen() }, + "Ui adapter sample" to { uiSampleFeatureFacade.api.cachingUiEffectsScreen() }, ).forEach { (text, screen) -> Button( modifier = Modifier.fillMaxWidth(), diff --git a/sample/core/modo-kombucha/build.gradle.kts b/sample/core/modo-kombucha/build.gradle.kts new file mode 100644 index 0000000..1accbca --- /dev/null +++ b/sample/core/modo-kombucha/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kombucha.android.library) +} + +android.namespace = "io.github.ikarenkov.kombucha.sample.modo_kombucha" + +dependencies { + implementation(libs.koin.core) + implementation(libs.modo) + + implementation(projects.kombucha.core) + implementation(projects.kombucha.uiAdapter) +} \ No newline at end of file diff --git a/sample/core/modo-kombucha/src/main/kotlin/io/github/ikarenkov/kombucha/sample/modo_kombucha/ModoKombuchaScreenModel.kt b/sample/core/modo-kombucha/src/main/kotlin/io/github/ikarenkov/kombucha/sample/modo_kombucha/ModoKombuchaScreenModel.kt new file mode 100644 index 0000000..cc8a5fd --- /dev/null +++ b/sample/core/modo-kombucha/src/main/kotlin/io/github/ikarenkov/kombucha/sample/modo_kombucha/ModoKombuchaScreenModel.kt @@ -0,0 +1,14 @@ +package io.github.ikarenkov.kombucha.sample.modo_kombucha + +import com.github.terrakok.modo.model.ScreenModel +import io.github.ikarenkov.kombucha.store.Store + +class ModoKombuchaScreenModel( + val store: Store +) : ScreenModel { + + override fun onDispose() { + store.cancel() + } + +} \ No newline at end of file diff --git a/sample/core/modules.gradle.kts b/sample/core/modules.gradle.kts index 87afb0d..758a589 100644 --- a/sample/core/modules.gradle.kts +++ b/sample/core/modules.gradle.kts @@ -1,3 +1,4 @@ include( - ":sample:core:feature-library", + ":sample:core:feature", + ":sample:core:modo-kombucha", ) \ No newline at end of file diff --git a/sample/features/modules.gradle.kts b/sample/features/modules.gradle.kts index 2325ed0..cf5eeb3 100644 --- a/sample/features/modules.gradle.kts +++ b/sample/features/modules.gradle.kts @@ -3,4 +3,5 @@ include( "sample:features:learnCompose:impl", "sample:features:game:impl", "sample:features:shikimori", + "sample:features:ui", ) \ No newline at end of file diff --git a/sample/features/ui/build.gradle.kts b/sample/features/ui/build.gradle.kts new file mode 100644 index 0000000..2397da4 --- /dev/null +++ b/sample/features/ui/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + alias(libs.plugins.kombucha.android.library) + alias(libs.plugins.kombucha.jetpackCompose.library) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "io.github.ikarenkov.sample.ui" +} + +tasks.withType { + useJUnitPlatform() +} + +dependencies { + implementation(libs.koin.android) + implementation(libs.debug.logcat) + + implementation(libs.modo) + implementation(libs.androidx.compose.material) + + implementation(projects.kombucha.core) + implementation(projects.kombucha.uiAdapter) + + implementation(projects.sample.core.feature) + implementation(projects.sample.core.modoKombucha) + + testImplementation(libs.test.kotlin) + testImplementation(libs.test.junit.jupiter) + testImplementation(libs.test.mockk) + testImplementation(libs.test.coroutines) + testImplementation(projects.kombucha.test) +} \ No newline at end of file diff --git a/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleApi.kt b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleApi.kt new file mode 100644 index 0000000..b26141b --- /dev/null +++ b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleApi.kt @@ -0,0 +1,13 @@ +package io.github.ikarenkov.sample.ui.api + +import com.github.terrakok.modo.Screen +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsScreen +import io.github.ikarenkov.sample.ui.impl.DetailsScreen + +class UiSampleApi internal constructor(){ + + fun cachingUiEffectsScreen(): Screen = CachingUiEffectsScreen() + + fun detailsScreen(id: String): Screen = DetailsScreen(id) + +} \ No newline at end of file diff --git a/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleDeps.kt b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleDeps.kt new file mode 100644 index 0000000..f4a545f --- /dev/null +++ b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleDeps.kt @@ -0,0 +1,7 @@ +package io.github.ikarenkov.sample.ui.api + +interface UiSampleDeps { + + fun openDetailsScreen(id: String) + +} \ No newline at end of file diff --git a/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleFeatureFacade.kt b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleFeatureFacade.kt new file mode 100644 index 0000000..5283917 --- /dev/null +++ b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/api/UiSampleFeatureFacade.kt @@ -0,0 +1,15 @@ +package io.github.ikarenkov.sample.ui.api + +import io.github.ikarenkov.kombucha.sample.core.feature.featureFacade +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsFeature +import io.github.ikarenkov.sample.ui.impl.NavigationEffHandler +import io.github.ikarenkov.sample.ui.impl.UpdatesEffectHandler + +val uiSampleFeatureFacade by lazy { + featureFacade("UiSample") { + scoped { UiSampleApi() } + factory { UpdatesEffectHandler() } + factory { NavigationEffHandler(get()) } + factory { CachingUiEffectsFeature(get(), get(), get()) } + } +} \ No newline at end of file diff --git a/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/CachingUiEffectsFeature.kt b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/CachingUiEffectsFeature.kt new file mode 100644 index 0000000..51d27ca --- /dev/null +++ b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/CachingUiEffectsFeature.kt @@ -0,0 +1,98 @@ +package io.github.ikarenkov.sample.ui.impl + +import io.github.ikarenkov.kombucha.eff_handler.EffectHandler +import io.github.ikarenkov.kombucha.eff_handler.adaptCast +import io.github.ikarenkov.kombucha.reducer.dslReducer +import io.github.ikarenkov.kombucha.store.Store +import io.github.ikarenkov.kombucha.store.StoreFactory +import io.github.ikarenkov.sample.ui.api.UiSampleDeps +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsFeature.Eff +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsFeature.Msg +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsFeature.State +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import java.util.UUID +import kotlin.time.Duration.Companion.seconds + +internal class CachingUiEffectsFeature( + storeFactory: StoreFactory, + updatedEffHandler: UpdatesEffectHandler, + navigationEffHandler: NavigationEffHandler +) : Store by storeFactory.create( + name = "CachingUiEffectsFeature", + initialState = State(emptyList()), + reducer = reducer, + initialEffects = setOf(Eff.Int.ObserveUpdates), + effectHandlers = arrayOf( + updatedEffHandler.adaptCast(), + navigationEffHandler.adaptCast() + ) +) { + + sealed interface Msg { + sealed interface Int : Msg { + + data class OnNewElement(val id: String) : Int + + } + + sealed interface Ext : Msg { + data class ItemClick(val id: String) : Ext + } + } + + sealed interface Eff { + sealed interface Int : Eff { + + data object ObserveUpdates : Int + data class OpenDetails(val id: String) : Int + + } + + sealed interface Ext : Eff { + + data class OnNewElement(val id: String) : Ext + + } + + } + + data class State( + val itemsIds: List + ) + +} + +internal val reducer = dslReducer { msg -> + when (msg) { + is Msg.Int.OnNewElement -> { + state { copy(itemsIds = itemsIds + msg.id) } + eff(Eff.Ext.OnNewElement(msg.id)) + } + is Msg.Ext.ItemClick -> eff(Eff.Int.OpenDetails(msg.id)) + } +} + +internal class UpdatesEffectHandler : EffectHandler { + + override fun handleEff(eff: Eff.Int.ObserveUpdates): Flow = flow { + while (currentCoroutineContext().isActive) { + delay(5.seconds) + emit(Msg.Int.OnNewElement(UUID.randomUUID().toString())) + } + } + +} + +internal class NavigationEffHandler( + private val deps: UiSampleDeps +) : EffectHandler { + + override fun handleEff(eff: Eff.Int.OpenDetails): Flow = flow { + deps.openDetailsScreen(eff.id) + } + +} \ No newline at end of file diff --git a/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/CachingUiEffectsScreen.kt b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/CachingUiEffectsScreen.kt new file mode 100644 index 0000000..dba8a53 --- /dev/null +++ b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/CachingUiEffectsScreen.kt @@ -0,0 +1,111 @@ +package io.github.ikarenkov.sample.ui.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.model.rememberScreenModel +import io.github.ikarenkov.kombucha.sample.modo_kombucha.ModoKombuchaScreenModel +import io.github.ikarenkov.kombucha.ui.uiBuilder +import io.github.ikarenkov.sample.ui.R +import io.github.ikarenkov.sample.ui.api.uiSampleFeatureFacade +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsFeature.Eff +import io.github.ikarenkov.sample.ui.impl.CachingUiEffectsFeature.Msg +import kotlinx.parcelize.Parcelize + +@Parcelize +internal class CachingUiEffectsScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content() { + val resources = LocalContext.current.resources + val store = rememberScreenModel { + val store = uiSampleFeatureFacade.scope.get() + val uiStore = store.uiBuilder().using { state -> + UiState( + state.itemsIds.map { resources.getString(R.string.item_title, it) } + ) + } + ModoKombuchaScreenModel(uiStore) + }.store + val state by store.state.collectAsState() + val scaffoldState = rememberScaffoldState() + LaunchedEffect(key1 = store) { + store.effects.collect { eff -> + when (eff) { + is Eff.Ext.OnNewElement -> { + scaffoldState.snackbarHostState.showSnackbar( + message = "On new element -> ${eff.id}", + duration = SnackbarDuration.Short + ) + } + } + } + } + Scaffold(scaffoldState = scaffoldState) { padings -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padings) + .padding(horizontal = 8.dp), + contentPadding = WindowInsets.statusBars.asPaddingValues() + ) { + items(state.items) { item -> + ItemCard( + item = item, + onClick = { store.accept(Msg.Ext.ItemClick(item)) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + internal data class UiState( + val items: List + ) + +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun ItemCard(item: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Card(modifier = modifier, onClick = onClick) { + Box(Modifier.padding(16.dp)) { + Text(text = item) + } + } +} + +@Preview +@Composable +private fun Preview() { + ItemCard(item = "title of the card", onClick = {}) +} \ No newline at end of file diff --git a/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/DetailsScreen.kt b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/DetailsScreen.kt new file mode 100644 index 0000000..fffa3b1 --- /dev/null +++ b/sample/features/ui/src/main/kotlin/io/github/ikarenkov/sample/ui/impl/DetailsScreen.kt @@ -0,0 +1,31 @@ +package io.github.ikarenkov.sample.ui.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import kotlinx.parcelize.Parcelize + +@Parcelize +internal class DetailsScreen( + val id: String, + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + @Composable + override fun Content() { + Box( + Modifier + .fillMaxSize() + .safeContentPadding() + ) { + Text(text = "Id: $id", style = MaterialTheme.typography.h3) + } + } + +} \ No newline at end of file diff --git a/sample/features/ui/src/main/res/values/strings.xml b/sample/features/ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..168f809 --- /dev/null +++ b/sample/features/ui/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Item #%s + \ No newline at end of file diff --git a/sample/modules.gradle.kts b/sample/modules.gradle.kts index 8c4e479..de8d138 100644 --- a/sample/modules.gradle.kts +++ b/sample/modules.gradle.kts @@ -1,6 +1,5 @@ include( ":sample:app", - ":sample:core:feature", ) apply(from = "./features/modules.gradle.kts") -//apply(from = "./core/modules.gradle.kts") \ No newline at end of file +apply(from = "./core/modules.gradle.kts") \ No newline at end of file