import androidx.lifecycle.ViewModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.core.qualifier.named import kotlin.coroutines.CoroutineContext ​ /** * Base ViewModel class for MVVM * * uses kotlin coroutines * * has: * - [contextProvider] - provides IO or Main context for coroutines * - [coroutineJob] - parent Job for all running coroutines, see [stop] * - [scope] - coroutine scope * - [uiCaller] - helper class to make IO-operations (network, data bases) * * MVVM Views (fragments, compose screens) should observe: * - [UiCaller.errorSharedFlow] to show errors * - [UiCaller.statusStateFlow] to track loading status (can be customized) */ abstract class BaseViewModel( protected val contextProvider: ContextProvider = ContextProvider(), private val coroutineJob: Job = SupervisorJob(), protected val scope: CoroutineScope = CoroutineScope(coroutineJob + contextProvider.IO), protected val uiCaller: UiCallerImpl = UiCallerImpl(scope, contextProvider) ) : ViewModel(), UiCaller by uiCaller { ​ open fun stop() { coroutineJob.cancelChildren() } ​ override fun onCleared() { super.onCleared() coroutineJob.cancel() } ​ protected infix fun FlowCollector.emit(value: T) { scope.launch { emit(value) } } } ​ /** * Interface to communicate with MVVM views */ interface UiCaller { ​ val statusStateFlow: StateFlow val errorSharedFlow: SharedFlow } ​ /** * Helper class, incapsulating request handling logic * can be distributed to ViewModel extentions (composition) * * - makes IO-requests: * - [makeRequest] with helper fun [unwrap] * - [makeRequestUnwrapped] * - [makeRequestFor] * - sends errors to [errorSharedFlow] * @see setError * - shows loading state via [statusStateFlow] * even for multiple requests * @see set */ class UiCallerImpl( val scope: CoroutineScope, private val contextProvider: ContextProvider = ContextProvider(), ) : UiCaller { ​ private val _statusStateFlow: MutableStateFlow = MutableStateFlow(Status.HIDE_LOADING) override val statusStateFlow: StateFlow = _statusStateFlow private val _errorSharedFlow: MutableSharedFlow = MutableSharedFlow() override val errorSharedFlow: SharedFlow = _errorSharedFlow ​ /** * Presentation layer handler for requests * launches [Job] in [scope], * sets loading state on [_statusStateFlow] * * [call] - suspend repository function * [statusFlow] - can be customized with different stateFlows (or null) * [resultBlock] - function to handle result (called in Main) * * returns [Job] for potential canceling */ fun makeRequest( call: suspend CoroutineScope.() -> T, statusFlow: MutableStateFlow? = _statusStateFlow, resultBlock: (suspend (T) -> Unit)? ): Job = scope.launch(contextProvider.Main) { set(Status.SHOW_LOADING, statusFlow) try { val result = withContext(contextProvider.IO, call) resultBlock?.invoke(result) } catch (e: Throwable) { if (e !is CancellationException) { setError(e.message.orEmpty()) } } set(Status.HIDE_LOADING, statusFlow) } ​ /** * To keep track of multiple [makeRequest] calls */ private var requestCounter = 0 ​ /** * Setting loading state for [statusFlow] * [_statusStateFlow] by default * can be customized with different statusFlow or `null` */ private fun set(status: Status, statusFlow: MutableStateFlow?) { statusFlow ?: return if (statusFlow === _statusStateFlow) { when (status) { Status.SHOW_LOADING -> { requestCounter++ } Status.HIDE_LOADING -> { requestCounter-- if (requestCounter > 0) return requestCounter = 0 } } } statusFlow.value = status } ​ fun setError(error: String) { scope.launch { _errorSharedFlow.emit(error) } } ​ fun makeRequestUnwrapped( call: suspend CoroutineScope.() -> RequestResult, statusFlow: MutableStateFlow? = _statusStateFlow, resultBlock: (suspend (T) -> Unit)? ): Job = scope.launch(contextProvider.Main) { set(Status.SHOW_LOADING, statusFlow) try { when (val result = withContext(contextProvider.IO, call)) { is RequestResult.Error -> setError(result.error) is RequestResult.Success -> resultBlock?.invoke(result.result) } } catch (e: Throwable) { if (e !is CancellationException) { setError(e.message.orEmpty()) } } set(Status.HIDE_LOADING, statusFlow) } ​ /** * Helper function to unwrap [RequestResult] */ fun unwrap( result: RequestResult, errorBlock: ((String) -> Unit)?, successBlock: (T) -> Unit ) = when (result) { is RequestResult.Success -> result.result?.let { successBlock(it) } is RequestResult.Error -> errorBlock?.invoke(result.error) } ​ fun makeRequestFor( statusStateFlow: MutableStateFlow, statusFlow: MutableStateFlow? = _statusStateFlow, call: suspend CoroutineScope.() -> RequestResult ) = makeRequest(call, statusFlow) { when (it) { is RequestResult.Success -> statusStateFlow.value = it.result is RequestResult.Error -> setError(it.error) } } } ​ /** * Used in [BaseViewModel] to make coroutine scope * should be mocked in tests with [Dispatchers.Unconfined] */ class ContextProvider : KoinComponent { val Main: CoroutineContext by inject(named("main"))//Dispatchers.Main val IO: CoroutineContext by inject(named("io"))//Dispatchers.IO } ​ /** * Loading status * @see UiCaller */ enum class Status { ​ SHOW_LOADING, ​ HIDE_LOADING, } ​ /** * Result wrapper for presentation layer * should be returned by repositories using [CoroutineCaller] */ sealed class RequestResult { ​ data class Success(val result: T) : RequestResult() data class Error(val error: String, val code: Int = 0) : RequestResult() }