Coverage Summary for Class: MviViewModel (com.georgeci.moneysurfer.utils)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| MviViewModel |
100%
(8/8)
|
50%
(1/2)
|
100%
(10/10)
|
96.7%
(58/60)
|
| MviViewModel$launch$1 |
100%
(1/1)
|
100%
(2/2)
|
100%
(3/3)
|
100%
(49/49)
|
| Total |
100%
(9/9)
|
75%
(3/4)
|
100%
(13/13)
|
98.2%
(107/109)
|
package com.georgeci.moneysurfer.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
abstract class MviViewModel<STATE : Any, EVENT, SIDE_EFFECT : Any>(
private val stateHolder: StateHolder<STATE>,
) : ViewModel(), StateFlow<STATE> by stateHolder {
constructor(initialState: STATE) : this(StateHolder(initialState))
val sideEffects = Effect<SIDE_EFFECT>()
val currentState: STATE get() = stateHolder.value
abstract fun onEvent(event: EVENT)
protected fun updateState(reducer: STATE.() -> STATE) {
stateHolder.updateState { it.reducer() }
}
protected fun postSideEffect(effect: SIDE_EFFECT) {
sideEffects.post(effect)
}
/**
* Runs [block] on [viewModelScope], catching every failure it produces.
*
* Failures are never swallowed: the throwable is always logged at Error severity,
* which is what `CrashReportingLogWriter` turns into a Crashlytics non-fatal. What
* [onError] controls is only who renders it — pass a handler to show the failure
* inside the screen's own state, or leave it null to escalate to the global error
* boundary via [UnhandledErrors] (issue #78).
*
* [CancellationException] is rethrown rather than handled: it is how structured
* concurrency unwinds a cancelled scope, not an app error. Swallowing it would log
* "Unhandled error" — and raise the global boundary — every time a screen leaves
* composition and `viewModelScope` is cleared.
*/
protected fun launch(
onError: ((Throwable) -> Unit)? = null,
block: suspend CoroutineScope.() -> Unit,
) {
viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught")
e: Throwable,
) {
Logger.withTag(logTag).e(e) { "Unhandled error in $logTag" }
if (onError != null) onError(e) else UnhandledErrors.report(e)
}
}
}
private val logTag: String get() = this::class.simpleName ?: "MviViewModel"
}
@NonRestartableComposable
@Composable
fun <SIDE_EFFECT : Any> Effect<SIDE_EFFECT>.HandleSideEffect(
handler: (SIDE_EFFECT) -> Unit,
) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
effectFlow.collect(handler)
}
}
}
@NonRestartableComposable
@Composable
fun <SIDE_EFFECT : Any> MviViewModel<*, *, SIDE_EFFECT>.HandleSideEffect(
handler: (SIDE_EFFECT) -> Unit,
) {
sideEffects.HandleSideEffect(handler)
}