Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ktlint) apply false
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "j
orbit-core = { group = "org.orbit-mvi", name = "orbit-core", version.ref = "orbit" }
orbit-compose = { group = "org.orbit-mvi", name = "orbit-compose", version.ref = "orbit" }
orbit-viewmodel = { group = "org.orbit-mvi", name = "orbit-viewmodel", version.ref = "orbit" }
orbit-test = { group = "org.orbit-mvi", name = "orbit-test", version.ref = "orbit" }

## retrofit
retrofit-bom = { group = "com.squareup.retrofit2", name = "retrofit-bom", version.ref = "retrofit" }
Expand All @@ -110,6 +111,7 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }

## Other
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
Expand Down Expand Up @@ -171,6 +173,7 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin"}
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
Expand Down
5 changes: 5 additions & 0 deletions presentation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.bitnagil.android.library)
alias(libs.plugins.bitnagil.android.compose.library)
alias(libs.plugins.kotlin.parcelize)
}

android {
Expand All @@ -12,4 +13,8 @@ dependencies {

implementation(libs.bundles.androidx.core)
implementation(libs.bundles.orbit)

testImplementation(libs.junit)
testImplementation(libs.kotlin.coroutines.test)
testImplementation(libs.orbit.test)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.threegap.bitnagil.presentation.common.mviviewmodel

interface MviIntent
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.threegap.bitnagil.presentation.common.mviviewmodel

interface MviSideEffect
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.threegap.bitnagil.presentation.common.mviviewmodel

import android.os.Parcelable

interface MviState : Parcelable
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.threegap.bitnagil.presentation.common.mviviewmodel

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.syntax.simple.SimpleSyntax
import org.orbitmvi.orbit.syntax.simple.intent
import org.orbitmvi.orbit.syntax.simple.postSideEffect
import org.orbitmvi.orbit.syntax.simple.reduce
import org.orbitmvi.orbit.viewmodel.container

abstract class MviViewModel<STATE : MviState, SIDE_EFFECT : MviSideEffect, INTENT : MviIntent>(
initState: STATE,
savedStateHandle: SavedStateHandle,
) : ContainerHost<STATE, SIDE_EFFECT>, ViewModel() {
override val container = container<STATE, SIDE_EFFECT>(initialState = initState, savedStateHandle = savedStateHandle)

val stateFlow: StateFlow<STATE> get() = container.stateFlow
val sideEffectFlow: Flow<SIDE_EFFECT> get() = container.sideEffectFlow

protected suspend fun SimpleSyntax<STATE, SIDE_EFFECT>.sendSideEffect(sideEffect: SIDE_EFFECT) = postSideEffect(sideEffect)

protected abstract suspend fun SimpleSyntax<STATE, SIDE_EFFECT>.reduceState(
intent: INTENT,
state: STATE,
): STATE?
Comment thread
l5x5l marked this conversation as resolved.

fun sendIntent(intent: INTENT) =
intent {
val newState = reduceState(intent, state)

newState?.let {
reduce { newState }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.threegap.bitnagil.presentation.common.mviviewmodel

import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.parcelize.Parcelize
import org.junit.Before
import org.junit.Test
import org.orbitmvi.orbit.syntax.simple.SimpleSyntax
import org.orbitmvi.orbit.test.test

@ExperimentalCoroutinesApi
class MviViewModelTest {
private lateinit var sampleMviViewModel: SampleMviViewModel

@Before
fun setUp() {
sampleMviViewModel = SampleMviViewModel(initState = SampleState(), savedStateHandle = SavedStateHandle())
}

@Test
fun `state는 호출 순서대로 갱신되어야 한다`() =
runTest {
sampleMviViewModel.test(testScope = this) {
containerHost.sendIntent(SampleIntent.Increase(number = 1))
containerHost.sendIntent(SampleIntent.Decrease(number = 2))
containerHost.sendIntent(SampleIntent.Increase(number = 3))

expectState { SampleState() }
expectState { SampleState(count = 1) }
expectState { SampleState(count = -1) }
expectState { SampleState(count = 2) }
}
}

@Test
fun `state와 sideEffect는 호출 순서대로 갱신되어야 한다`() =
runTest {
sampleMviViewModel.test(testScope = this) {
containerHost.sendIntent(SampleIntent.Increase(number = 1))
containerHost.sendIntent(SampleIntent.Clear)
containerHost.sendIntent(SampleIntent.Decrease(number = 2))
containerHost.sendIntent(SampleIntent.Increase(number = 3))

expectState { SampleState() }
expectState { SampleState(count = 1) }
expectSideEffect(SampleSideEffect.ShowToast("Clear"))
expectState { SampleState() }
expectState { SampleState(count = -2) }
expectState { SampleState(count = 1) }
}
}

// only for test
private class SampleMviViewModel(
initState: SampleState,
savedStateHandle: SavedStateHandle,
) : MviViewModel<SampleState, SampleSideEffect, SampleIntent>(initState, savedStateHandle) {
override suspend fun SimpleSyntax<SampleState, SampleSideEffect>.reduceState(
intent: SampleIntent,
state: SampleState,
): SampleState? {
val newState =
when (intent) {
is SampleIntent.Decrease -> state.copy(count = state.count - intent.number)
is SampleIntent.Increase -> state.copy(count = state.count + intent.number)
SampleIntent.Clear -> {
sendSideEffect(sideEffect = SampleSideEffect.ShowToast("Clear"))
state.copy(count = 0)
}
}
return newState
}
}

@Parcelize
private data class SampleState(
val count: Int = 0,
) : MviState

private sealed class SampleSideEffect : MviSideEffect {
data class ShowToast(val message: String) : SampleSideEffect()
}

private sealed class SampleIntent : MviIntent {
data class Increase(val number: Int) : SampleIntent()

data class Decrease(val number: Int) : SampleIntent()

object Clear : SampleIntent()
}
}