From a0eb75501368a48b269c74cd3bd77391ecca1aff Mon Sep 17 00:00:00 2001 From: Mark Dubkov Date: Fri, 24 Nov 2023 18:33:13 +0300 Subject: [PATCH] feat: SelfHostedViewModel adding --- ....kts => kmp-library-convention.gradle.kts} | 28 +++++++---- compose-annotation/build.gradle.kts | 2 +- .../app/meetacy/vm/compose/Immutable.kt | 0 core/build.gradle.kts | 2 +- .../kotlin/app/meetacy/vm/ViewModel.kt | 4 ++ .../kotlin/app/meetacy/vm/ViewModel.kt | 0 gradle/libs.versions.toml | 5 +- mvi/build.gradle.kts | 5 +- .../kotlin/app/meetacy/vm/mvi/Intent.kt | 15 ++++++ .../app/meetacy/vm/mvi/IntentBuilder.kt | 31 ++++++++++++ .../kotlin/app/meetacy/vm/mvi/IntentHost.kt | 24 +++++++++ .../kotlin/app/meetacy/vm/mvi/MviViewModel.kt | 6 +-- .../kotlin/app/meetacy/vm/mvi/StateHolder.kt | 14 ++++++ .../kotlin/app/meetacy/vm/mvi/StateHost.kt | 40 +++++++++++++++ .../meetacy/vm/mvi/StateHostedViewModel.kt | 29 +++++++++++ .../kotlin/app/meetacy/vm/mvi/SignUpHost.kt | 49 +++++++++++++++++++ .../kotlin/app/meetacy/vm/mvi/SomeUseCase.kt | 8 +++ .../app/meetacy/vm/mvi/SomeViewModel.kt | 20 ++++++++ .../app/meetacy/vm/mvi/SomeViewModelTest.kt | 36 ++++++++++++++ .../app/meetacy/vm/mvi/StateHostTest.kt | 44 +++++++++++++++++ 20 files changed, 345 insertions(+), 17 deletions(-) rename build-logic/src/main/kotlin/{kmm-library-convention.gradle.kts => kmp-library-convention.gradle.kts} (62%) rename compose-annotation/src/{iosMain => nonAndroidMain}/kotlin/app/meetacy/vm/compose/Immutable.kt (100%) rename core/src/{iosMain => nonAndroidMain}/kotlin/app/meetacy/vm/ViewModel.kt (100%) create mode 100644 mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/Intent.kt create mode 100644 mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentBuilder.kt create mode 100644 mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentHost.kt create mode 100644 mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHolder.kt create mode 100644 mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHost.kt create mode 100644 mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHostedViewModel.kt create mode 100644 mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SignUpHost.kt create mode 100644 mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeUseCase.kt create mode 100644 mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModel.kt create mode 100644 mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModelTest.kt create mode 100644 mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/StateHostTest.kt diff --git a/build-logic/src/main/kotlin/kmm-library-convention.gradle.kts b/build-logic/src/main/kotlin/kmp-library-convention.gradle.kts similarity index 62% rename from build-logic/src/main/kotlin/kmm-library-convention.gradle.kts rename to build-logic/src/main/kotlin/kmp-library-convention.gradle.kts index 9401764..73f887f 100644 --- a/build-logic/src/main/kotlin/kmm-library-convention.gradle.kts +++ b/build-logic/src/main/kotlin/kmp-library-convention.gradle.kts @@ -17,21 +17,31 @@ kotlin { iosArm64() iosX64() iosSimulatorArm64() + jvm() sourceSets { val commonMain by getting val commonTest by getting - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting + val nativeTargets = listOf( + "iosArm32", + "iosArm64", + "iosX64", + "iosSimulatorArm64", + ) - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } + val targetWithoutAndroid = nativeTargets + listOf( + "jvm", + ) + + val nonAndroidMain by creating + nonAndroidMain.dependsOn(commonMain) + + targetWithoutAndroid.mapNotNull { findByName("${it}Main") } + .forEach { it.dependsOn(nonAndroidMain) } + + val nonAndroidTest by creating + nonAndroidTest.dependsOn(commonTest) val iosX64Test by getting val iosArm64Test by getting diff --git a/compose-annotation/build.gradle.kts b/compose-annotation/build.gradle.kts index b101831..7646865 100644 --- a/compose-annotation/build.gradle.kts +++ b/compose-annotation/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("kmm-library-convention") + id("kmp-library-convention") } version = libs.versions.mvm.get() diff --git a/compose-annotation/src/iosMain/kotlin/app/meetacy/vm/compose/Immutable.kt b/compose-annotation/src/nonAndroidMain/kotlin/app/meetacy/vm/compose/Immutable.kt similarity index 100% rename from compose-annotation/src/iosMain/kotlin/app/meetacy/vm/compose/Immutable.kt rename to compose-annotation/src/nonAndroidMain/kotlin/app/meetacy/vm/compose/Immutable.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b568b14..1ddccf4 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("kmm-library-convention") + id("kmp-library-convention") } version = libs.versions.mvm.get() diff --git a/core/src/commonMain/kotlin/app/meetacy/vm/ViewModel.kt b/core/src/commonMain/kotlin/app/meetacy/vm/ViewModel.kt index 5948900..60ab655 100644 --- a/core/src/commonMain/kotlin/app/meetacy/vm/ViewModel.kt +++ b/core/src/commonMain/kotlin/app/meetacy/vm/ViewModel.kt @@ -1,6 +1,10 @@ package app.meetacy.vm +import app.meetacy.vm.extension.launchIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch public expect open class ViewModel() { diff --git a/core/src/iosMain/kotlin/app/meetacy/vm/ViewModel.kt b/core/src/nonAndroidMain/kotlin/app/meetacy/vm/ViewModel.kt similarity index 100% rename from core/src/iosMain/kotlin/app/meetacy/vm/ViewModel.kt rename to core/src/nonAndroidMain/kotlin/app/meetacy/vm/ViewModel.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f58172a..9173504 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidGradle = "7.4.2" androidLifecycleVersion = "2.6.2" kotlinxCoroutines = "1.7.3" -mvm = "0.0.6" +mvm = "0.0.7" [libraries] @@ -14,9 +14,10 @@ lifecycleKtx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.re androidViewModel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidLifecycleVersion" } composeFoundation = { module = "androidx.compose.foundation:foundation" } kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinxCoroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } # https://developer.android.com/jetpack/compose/bom/bom-mapping -composeBOM = "androidx.compose:compose-bom:2023.09.00" +composeBOM = "androidx.compose:compose-bom:2023.10.01" # gradle plugins kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/mvi/build.gradle.kts b/mvi/build.gradle.kts index 7313a31..3da4e41 100644 --- a/mvi/build.gradle.kts +++ b/mvi/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("kmm-library-convention") + id("kmp-library-convention") } version = libs.versions.mvm.get() @@ -12,4 +12,7 @@ dependencies { commonMainApi(projects.vm.core) commonMainApi(projects.vm.composeAnnotation) androidMainApi(projects.vm.core) + + commonTestImplementation(kotlin("test")) + commonTestImplementation(libs.kotlinxCoroutinesTest) } diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/Intent.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/Intent.kt new file mode 100644 index 0000000..2082084 --- /dev/null +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/Intent.kt @@ -0,0 +1,15 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.flow.Flow + +public interface Intent { + + public fun flowOf(state: TState): Flow> + + public sealed interface Update { + + public data class State(public val state: TState): Update + + public data class Effect(public val effect: TEffect): Update + } +} \ No newline at end of file diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentBuilder.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentBuilder.kt new file mode 100644 index 0000000..b6ba0d5 --- /dev/null +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentBuilder.kt @@ -0,0 +1,31 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.FlowCollector +import kotlin.jvm.JvmName + +@DslMarker +public annotation class IntentBuilderDsl + +public class IntentBuilder( + initial: TState, + public val scope: CoroutineScope, + private val collector: FlowCollector>, +) { + private var _state = initial + + @IntentBuilderDsl + public val currentState: TState get() = _state + + @IntentBuilderDsl + public suspend fun reduce(transform: suspend TState.() -> TState) { + _state = currentState.transform() + collector.emit(Intent.Update.State(currentState)) + } + + @JvmName("performEffect") + @IntentBuilderDsl + public suspend fun perform(effect: TEffect) { + collector.emit(Intent.Update.Effect(effect)) + } +} diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentHost.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentHost.kt new file mode 100644 index 0000000..92ae8ac --- /dev/null +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentHost.kt @@ -0,0 +1,24 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow + +public interface IntentHost + +@IntentBuilderDsl +public inline fun IntentHost.intent( + crossinline builder: suspend IntentBuilder.() -> Unit +): Intent = buildIntent(builder) + +public inline fun buildIntent( + crossinline builder: suspend IntentBuilder.() -> Unit +): Intent = object : Intent { + override fun flowOf(state: TState): Flow> = channelFlow { + val intent = IntentBuilder( + state, + scope = this, + collector = { this.send(it) } + ) + intent.run { builder() } + } +} diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/MviViewModel.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/MviViewModel.kt index 617e045..3ee32b4 100644 --- a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/MviViewModel.kt +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/MviViewModel.kt @@ -1,16 +1,16 @@ package app.meetacy.vm.mvi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.launch import app.meetacy.vm.ViewModel import app.meetacy.vm.extension.launchIn import app.meetacy.vm.flow.CSharedFlow import app.meetacy.vm.flow.CStateFlow import app.meetacy.vm.flow.cSharedFlow import app.meetacy.vm.flow.cStateFlow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch public abstract class MviViewModel(initialState: State) : ViewModel() { diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHolder.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHolder.kt new file mode 100644 index 0000000..db8db48 --- /dev/null +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHolder.kt @@ -0,0 +1,14 @@ +package app.meetacy.vm.mvi + +import app.meetacy.vm.flow.CFlow +import app.meetacy.vm.flow.CStateFlow + +public abstract class StateHolder { + + public abstract val effects: CFlow + public abstract val states: CStateFlow + + public abstract suspend fun accept(intent: Intent) + public abstract suspend fun accept(effect: TEffect) + public abstract fun accept(newState: TState) +} \ No newline at end of file diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHost.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHost.kt new file mode 100644 index 0000000..108f6a0 --- /dev/null +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHost.kt @@ -0,0 +1,40 @@ +package app.meetacy.vm.mvi + +import app.meetacy.vm.flow.CFlow +import app.meetacy.vm.flow.CStateFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +public interface StateHost { + + public val holder: StateHolder +} + +public fun StateHost.holder( + initial: TState +): StateHolder = object : StateHolder() { + private val _effects: Channel = Channel(Channel.BUFFERED) + private val _states: MutableStateFlow = MutableStateFlow(initial) + + override val effects: CFlow = CFlow(_effects.receiveAsFlow()) + override val states: CStateFlow = CStateFlow(_states.asStateFlow()) + + private val collector: FlowCollector> = FlowCollector { value -> + when (value) { + is Intent.Update.State -> _states.emit(value.state) + is Intent.Update.Effect -> _effects.send(value.effect) + } + } + + override suspend fun accept(effect: TEffect) { + _effects.send(effect) + } + + override fun accept(newState: TState) { + _states.update { newState } + } + + override suspend fun accept(intent: Intent) { + intent.flowOf(states.value).collect(collector) + } +} \ No newline at end of file diff --git a/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHostedViewModel.kt b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHostedViewModel.kt new file mode 100644 index 0000000..fc52735 --- /dev/null +++ b/mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHostedViewModel.kt @@ -0,0 +1,29 @@ +package app.meetacy.vm.mvi + +import app.meetacy.vm.ViewModel +import app.meetacy.vm.extension.launchIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +public abstract class StateHostedViewModel: ViewModel(), StateHost { + + protected fun viewModelScopeLaunch(block: suspend CoroutineScope.() -> Unit) { + viewModelScope.launch(block = block) + } + + protected fun Flow.observe(block: suspend (T) -> Unit): Job = launchIn(viewModelScope, block) + + protected fun accept(intent: Intent) { + viewModelScope.launch { holder.accept(intent) } + } + + protected fun mutateState(transform: TState.() -> TState) { + holder.accept(holder.states.value.transform()) + } + + protected fun accept(effect: TEffect) { + viewModelScope.launch { holder.accept(effect) } + } +} diff --git a/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SignUpHost.kt b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SignUpHost.kt new file mode 100644 index 0000000..09d9808 --- /dev/null +++ b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SignUpHost.kt @@ -0,0 +1,49 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.CoroutineScope + +object SignUpHost: IntentHost { + interface RegisterUseCase { + + suspend fun register(userName: String): Result + } + + sealed interface SideEffect { + object RouteMain : SideEffect + object ShowError : SideEffect + } + + data class State( + val userName: String, + val isLoading: Boolean + ) { + companion object { + val Initial = State( + userName = "", + isLoading = true + ) + } + } + + fun signUpIntent( + text: String, + useCase: RegisterUseCase + ) = intent { + reduce { + copy( + isLoading = true, + userName = text + ) + } + + useCase.register(currentState.userName).onSuccess { + perform(SideEffect.RouteMain) + }.onFailure { + perform(SideEffect.ShowError) + } + + reduce { copy(isLoading = false) } + } +} + + diff --git a/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeUseCase.kt b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeUseCase.kt new file mode 100644 index 0000000..052ee7a --- /dev/null +++ b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeUseCase.kt @@ -0,0 +1,8 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.flow.Flow + +interface SomeUseCase { + + fun getFlow(): Flow +} \ No newline at end of file diff --git a/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModel.kt b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModel.kt new file mode 100644 index 0000000..473dfb4 --- /dev/null +++ b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModel.kt @@ -0,0 +1,20 @@ +package app.meetacy.vm.mvi + +import app.meetacy.vm.extension.launchIn + +class SomeViewModel: StateHostedViewModel() { + + override val holder: StateHolder = holder(State()) + data class State(val isLoading: Boolean = true) + + sealed interface Effect + + companion object : IntentHost { + + fun subscription(useCase: SomeUseCase) = intent { + useCase.getFlow().launchIn(scope) { value -> + reduce { copy(isLoading = value % 3 == 0) } + } + } + } +} \ No newline at end of file diff --git a/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModelTest.kt b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModelTest.kt new file mode 100644 index 0000000..a627ec5 --- /dev/null +++ b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModelTest.kt @@ -0,0 +1,36 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SomeViewModelTest { + + @Test + @OptIn(ExperimentalStdlibApi::class) + fun test() = runTest { + val useCase: SomeUseCase = object : SomeUseCase { + override fun getFlow(): Flow = flow { + for (element in 0..<3) { + emit(element) + delay(1000L) + } + } + } + + val intent = SomeViewModel.subscription(useCase) + + assertEquals( + expected = listOf( + Intent.Update.State(SomeViewModel.State(true)), + Intent.Update.State(SomeViewModel.State(false)), + Intent.Update.State(SomeViewModel.State(false)), + ), + actual = intent.flowOf(SomeViewModel.State()).toList() + ) + } +} \ No newline at end of file diff --git a/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/StateHostTest.kt b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/StateHostTest.kt new file mode 100644 index 0000000..9c688a0 --- /dev/null +++ b/mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/StateHostTest.kt @@ -0,0 +1,44 @@ +package app.meetacy.vm.mvi + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class StateHostTest { + + private fun intent(text: String) = SignUpHost.signUpIntent( + text = text, + useCase = object : SignUpHost.RegisterUseCase { + override suspend fun register(userName: String): Result = runCatching { + if (userName == "userName2") throw IllegalStateException("Some error") + } + } + ) + + @Test + fun testSignUpIntentWithoutThrows() = runTest { + val updates = intent(text = "userName").flowOf(SignUpHost.State.Initial).toList() + assertEquals( + expected = listOf( + Intent.Update.State(SignUpHost.State(isLoading = true, userName = "userName")), + Intent.Update.Effect(SignUpHost.SideEffect.RouteMain), + Intent.Update.State(SignUpHost.State(isLoading = false, userName = "userName")) + ), + updates + ) + } + + @Test + fun testSignUpIntentWithThrows() = runTest { + val updates = intent(text = "userName2").flowOf(SignUpHost.State.Initial).toList() + assertEquals( + expected = listOf( + Intent.Update.State(SignUpHost.State(isLoading = true, userName = "userName2")), + Intent.Update.Effect(SignUpHost.SideEffect.ShowError), + Intent.Update.State(SignUpHost.State(isLoading = false, userName = "userName2")) + ), + updates + ) + } +}