Skip to content

Commit

Permalink
feat: SelfHostedViewModel adding
Browse files Browse the repository at this point in the history
kramlex committed Nov 24, 2023
1 parent b853256 commit a0eb755
Showing 20 changed files with 345 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion compose-annotation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
plugins {
id("kmm-library-convention")
id("kmp-library-convention")
}

version = libs.versions.mvm.get()
2 changes: 1 addition & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
plugins {
id("kmm-library-convention")
id("kmp-library-convention")
}

version = libs.versions.mvm.get()
4 changes: 4 additions & 0 deletions core/src/commonMain/kotlin/app/meetacy/vm/ViewModel.kt
Original file line number Diff line number Diff line change
@@ -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() {

5 changes: 3 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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" }
5 changes: 4 additions & 1 deletion mvi/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 15 additions & 0 deletions mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/Intent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package app.meetacy.vm.mvi

import kotlinx.coroutines.flow.Flow

public interface Intent<TState, out TEffect> {

public fun flowOf(state: TState): Flow<Update<TState, TEffect>>

public sealed interface Update<out TState, out TEffect> {

public data class State<TState>(public val state: TState): Update<TState, Nothing>

public data class Effect<TEffect>(public val effect: TEffect): Update<Nothing, TEffect>
}
}
31 changes: 31 additions & 0 deletions mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentBuilder.kt
Original file line number Diff line number Diff line change
@@ -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<TState, TEffect>(
initial: TState,
public val scope: CoroutineScope,
private val collector: FlowCollector<Intent.Update<TState, TEffect>>,
) {
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))
}
}
24 changes: 24 additions & 0 deletions mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/IntentHost.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package app.meetacy.vm.mvi

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow

public interface IntentHost<TState, TEffect>

@IntentBuilderDsl
public inline fun <TState, TEffect> IntentHost<TState, TEffect>.intent(
crossinline builder: suspend IntentBuilder<TState, TEffect>.() -> Unit
): Intent<TState, TEffect> = buildIntent(builder)

public inline fun <TState, TEffect> buildIntent(
crossinline builder: suspend IntentBuilder<TState, TEffect>.() -> Unit
): Intent<TState, TEffect> = object : Intent<TState, TEffect> {
override fun flowOf(state: TState): Flow<Intent.Update<TState, TEffect>> = channelFlow {
val intent = IntentBuilder(
state,
scope = this,
collector = { this.send(it) }
)
intent.run { builder() }
}
}
6 changes: 3 additions & 3 deletions mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/MviViewModel.kt
Original file line number Diff line number Diff line change
@@ -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<State : Any, Action, Event>(initialState: State) : ViewModel() {

14 changes: 14 additions & 0 deletions mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHolder.kt
Original file line number Diff line number Diff line change
@@ -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<TState, TEffect> {

public abstract val effects: CFlow<TEffect>
public abstract val states: CStateFlow<TState>

public abstract suspend fun accept(intent: Intent<TState, TEffect>)
public abstract suspend fun accept(effect: TEffect)
public abstract fun accept(newState: TState)
}
40 changes: 40 additions & 0 deletions mvi/src/commonMain/kotlin/app/meetacy/vm/mvi/StateHost.kt
Original file line number Diff line number Diff line change
@@ -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<TState, TEffect> {

public val holder: StateHolder<TState, TEffect>
}

public fun <TState, TEffect> StateHost<TState, TEffect>.holder(
initial: TState
): StateHolder<TState, TEffect> = object : StateHolder<TState, TEffect>() {
private val _effects: Channel<TEffect> = Channel(Channel.BUFFERED)
private val _states: MutableStateFlow<TState> = MutableStateFlow(initial)

override val effects: CFlow<TEffect> = CFlow(_effects.receiveAsFlow())
override val states: CStateFlow<TState> = CStateFlow(_states.asStateFlow())

private val collector: FlowCollector<Intent.Update<TState, TEffect>> = 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<TState, TEffect>) {
intent.flowOf(states.value).collect(collector)
}
}
Original file line number Diff line number Diff line change
@@ -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<TState, TEffect>: ViewModel(), StateHost<TState, TEffect> {

protected fun viewModelScopeLaunch(block: suspend CoroutineScope.() -> Unit) {
viewModelScope.launch(block = block)
}

protected fun <T> Flow<T>.observe(block: suspend (T) -> Unit): Job = launchIn(viewModelScope, block)

protected fun accept(intent: Intent<TState, TEffect>) {
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) }
}
}
49 changes: 49 additions & 0 deletions mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SignUpHost.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package app.meetacy.vm.mvi

import kotlinx.coroutines.CoroutineScope

object SignUpHost: IntentHost<SignUpHost.State, SignUpHost.SideEffect > {
interface RegisterUseCase {

suspend fun register(userName: String): Result<Unit>
}

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) }
}
}


8 changes: 8 additions & 0 deletions mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeUseCase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.meetacy.vm.mvi

import kotlinx.coroutines.flow.Flow

interface SomeUseCase {

fun getFlow(): Flow<Int>
}
20 changes: 20 additions & 0 deletions mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app.meetacy.vm.mvi

import app.meetacy.vm.extension.launchIn

class SomeViewModel: StateHostedViewModel<SomeViewModel.State, SomeViewModel.Effect>() {

override val holder: StateHolder<State, Effect> = holder(State())
data class State(val isLoading: Boolean = true)

sealed interface Effect

companion object : IntentHost<State, Effect> {

fun subscription(useCase: SomeUseCase) = intent {
useCase.getFlow().launchIn(scope) { value ->
reduce { copy(isLoading = value % 3 == 0) }
}
}
}
}
36 changes: 36 additions & 0 deletions mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/SomeViewModelTest.kt
Original file line number Diff line number Diff line change
@@ -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<Int> = 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()
)
}
}
44 changes: 44 additions & 0 deletions mvi/src/commonTest/kotlin/app/meetacy/vm/mvi/StateHostTest.kt
Original file line number Diff line number Diff line change
@@ -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<Unit> = 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
)
}
}

0 comments on commit a0eb755

Please sign in to comment.