Coverage Summary for Class: MviViewModelKt (com.georgeci.moneysurfer.utils)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| MviViewModelKt |
0%
(0/2)
|
0%
(0/6)
|
0%
(0/5)
|
0%
(0/119)
|
| MviViewModelKt$HandleSideEffect$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/22)
|
| MviViewModelKt$HandleSideEffect$1$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/20)
|
| MviViewModelKt$HandleSideEffect$1$1$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/6)
|
| Total |
0%
(0/5)
|
0%
(0/6)
|
0%
(0/8)
|
0%
(0/167)
|
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)
}