diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt new file mode 100644 index 000000000..ba72f6499 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt @@ -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( + initialState: S, +) : ViewModel() { + protected val mutableStateFlow: MutableStateFlow = MutableStateFlow(initialState) + protected val eventChannel: Channel = Channel(capacity = Channel.UNLIMITED) + private val internalActionChannel: Channel = Channel(capacity = Channel.UNLIMITED) + + /** + * A [StateFlow] representing state updates. + */ + val stateFlow: StateFlow = 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 = eventChannel.receiveAsFlow() + + /** + * A [SendChannel] for sending actions to the ViewModel for processing. + */ + val actionChannel: SendChannel = 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) } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt index c9cd4182b..79066e1e4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt @@ -21,7 +21,7 @@ fun RootNavScreen( viewModel: RootNavViewModel = viewModel(), ) { val navController = rememberNavController() - val state by viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() NavHost( navController = navController, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt index 6465de507..f796f6bad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt @@ -1,12 +1,9 @@ package com.x8bit.bitwarden.ui.feature.rootnav -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -14,18 +11,20 @@ import javax.inject.Inject * Manages root level navigation state of the application. */ @HiltViewModel -class RootNavViewModel @Inject constructor() : ViewModel() { - - private val _state = MutableStateFlow(RootNavState.Splash) - val state: StateFlow = _state.asStateFlow() +class RootNavViewModel @Inject constructor() : + BaseViewModel( + initialState = RootNavState.Splash, + ) { init { viewModelScope.launch { @Suppress("MagicNumber") delay(1000) - _state.value = RootNavState.Login + mutableStateFlow.value = RootNavState.Login } } + + override fun handleAction(action: Unit) = Unit } /** diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt index a5f83e145..9fa84c10c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt @@ -12,13 +12,13 @@ class RootNavViewModelTests : BaseViewModelTest() { @Test fun `initial state should be splash`() { val viewModel = RootNavViewModel() - assert(viewModel.state.value is RootNavState.Splash) + assert(viewModel.stateFlow.value is RootNavState.Splash) } @Test fun `state should move from splash to login`() = runTest { val viewModel = RootNavViewModel() - viewModel.state.test { + viewModel.stateFlow.test { assert(awaitItem() is RootNavState.Splash) assert(awaitItem() is RootNavState.Login) }