BIT-145: Add BaseViewModel and update existing ViewModels (#11)

This commit is contained in:
Brian Yencho 2023-08-28 09:37:05 -05:00 committed by Álison Fernandes
parent ee199b9e9f
commit 2ff4912b92
4 changed files with 90 additions and 12 deletions

View file

@ -0,0 +1,79 @@
package com.x8bit.bitwarden.ui.base
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
/**
* A base [ViewModel] that helps enforce the unidirectional data flow pattern and associated
* responsibilities of a typical ViewModel:
*
* - Maintaining and emitting a current state (of type [S]) with the given `initialState`.
* - Emitting one-shot events as needed (of type [E]). These should be rare and are typically
* reserved for things such as non-state based navigation.
* - Receiving actions (of type [A]) that may induce changes in the current state, trigger an
* event emission, or both.
*/
abstract class BaseViewModel<S, E, A>(
initialState: S,
) : ViewModel() {
protected val mutableStateFlow: MutableStateFlow<S> = MutableStateFlow(initialState)
protected val eventChannel: Channel<E> = Channel(capacity = Channel.UNLIMITED)
private val internalActionChannel: Channel<A> = Channel(capacity = Channel.UNLIMITED)
/**
* A [StateFlow] representing state updates.
*/
val stateFlow: StateFlow<S> = mutableStateFlow.asStateFlow()
/**
* A [Flow] of one-shot events. These may be received and consumed by only a single consumer.
* Any additional consumers will receive no events.
*/
val eventFlow: Flow<E> = eventChannel.receiveAsFlow()
/**
* A [SendChannel] for sending actions to the ViewModel for processing.
*/
val actionChannel: SendChannel<A> = internalActionChannel
init {
viewModelScope.launch {
internalActionChannel
.consumeAsFlow()
.collect { action ->
handleAction(action)
}
}
}
/**
* Handles the given [action] in a synchronous manner.
*
* Any changes to internal state that first require asynchronous work should post a follow-up
* action that may be used to then update the state synchronously.
*/
protected abstract fun handleAction(action: A): Unit
/**
* Helper method for sending an internal action.
*/
protected suspend fun sendAction(action: A) {
actionChannel.send(action)
}
/**
* Helper method for sending an event.
*/
protected fun sendEvent(event: E) {
viewModelScope.launch { eventChannel.send(event) }
}
}

View file

@ -21,7 +21,7 @@ fun RootNavScreen(
viewModel: RootNavViewModel = viewModel(), viewModel: RootNavViewModel = viewModel(),
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
NavHost( NavHost(
navController = navController, navController = navController,

View file

@ -1,12 +1,9 @@
package com.x8bit.bitwarden.ui.feature.rootnav package com.x8bit.bitwarden.ui.feature.rootnav
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -14,18 +11,20 @@ import javax.inject.Inject
* Manages root level navigation state of the application. * Manages root level navigation state of the application.
*/ */
@HiltViewModel @HiltViewModel
class RootNavViewModel @Inject constructor() : ViewModel() { class RootNavViewModel @Inject constructor() :
BaseViewModel<RootNavState, Unit, Unit>(
private val _state = MutableStateFlow<RootNavState>(RootNavState.Splash) initialState = RootNavState.Splash,
val state: StateFlow<RootNavState> = _state.asStateFlow() ) {
init { init {
viewModelScope.launch { viewModelScope.launch {
@Suppress("MagicNumber") @Suppress("MagicNumber")
delay(1000) delay(1000)
_state.value = RootNavState.Login mutableStateFlow.value = RootNavState.Login
} }
} }
override fun handleAction(action: Unit) = Unit
} }
/** /**

View file

@ -12,13 +12,13 @@ class RootNavViewModelTests : BaseViewModelTest() {
@Test @Test
fun `initial state should be splash`() { fun `initial state should be splash`() {
val viewModel = RootNavViewModel() val viewModel = RootNavViewModel()
assert(viewModel.state.value is RootNavState.Splash) assert(viewModel.stateFlow.value is RootNavState.Splash)
} }
@Test @Test
fun `state should move from splash to login`() = runTest { fun `state should move from splash to login`() = runTest {
val viewModel = RootNavViewModel() val viewModel = RootNavViewModel()
viewModel.state.test { viewModel.stateFlow.test {
assert(awaitItem() is RootNavState.Splash) assert(awaitItem() is RootNavState.Splash)
assert(awaitItem() is RootNavState.Login) assert(awaitItem() is RootNavState.Login)
} }