Skip to content

Instantly share code, notes, and snippets.

@Lucodivo
Last active September 17, 2024 16:49
Show Gist options
  • Save Lucodivo/85669d07e3bea9caa6d99cf6a94e08d1 to your computer and use it in GitHub Desktop.
Save Lucodivo/85669d07e3bea9caa6d99cf6a94e08d1 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 4.1 - stateIn() fix
// UI state enums
enum class SettingsDropdownMenuState {
None,
DarkMode,
ColorPalette,
HighContrast,
Typography,
ImageQuality,
}
enum class SettingsAlertDialogState {
None,
DeleteAllData,
ImageQuality,
}
// UI state
data class SettingsUIState (
val clearCacheEnabled: Boolean,
val highContrastEnabled: Boolean,
val dropdownMenuState: SettingsDropdownMenuState,
val alertDialogState: SettingsAlertDialogState,
val darkMode: DarkMode,
val colorPalette: ColorPalette,
val highContrast: HighContrast,
val imageQuality: ImageQuality,
val typography: Typography,
)
// UI effects
sealed interface SettingsUIEffect {
sealed interface NavigationDestination {
data object TipsAndInfo: NavigationDestination
data object Statistics: NavigationDestination
data class Web(val url: String): NavigationDestination
}
data object CachePurged: SettingsUIEffect
data object AllDataDeleted: SettingsUIEffect
data object RateAndReviewRequest: SettingsUIEffect
data class Navigation(val dest: NavigationDestination): SettingsUIEffect
}
// UI events
sealed interface SettingsUIEvent {
data object ClickClearCache: SettingsUIEvent
data class SelectDarkMode(val mode: DarkMode): SettingsUIEvent
// ...25 more UI events
}
// enough time to prevent flows from timing out on basic configuration changes
const val STATEIN_TIMEOUT_MILLIS = 5_000L
// ViewModel
abstract class MoleculeViewModel<UIEvent, UIState, UIEffect>(
initialState: UIState,
): ViewModel() {
private val uiScope =
CoroutineScope(
viewModelScope.coroutineContext +
AndroidUiDispatcher.Main
)
private val _uiEvents = Channel<UIEvent>(capacity = 20)
private val _uiEffects =
MutableSharedFlow<UIEffect>(extraBufferCapacity = 20)
private var cachedUiState: UIState = initialState
// UI state & effect providers
val uiEffect: SharedFlow<UIEffect> = _uiEffects
val uiState: StateFlow<UIState> = moleculeFlow(mode = ContextClock) {
uiState(
cachedUiState,
_uiEvents.receiveAsFlow(),
::launchUiEffect,
)
}.onEach { uiState ->
cachedUiState = uiState
}.stateIn(
scope = uiScope,
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS),
initialValue = cachedUiState,
)
@Composable
protected abstract fun uiState(
initialState: UIState,
uiEvents: Flow<UIEvent>,
launchUiEffect: (UIEffect) -> Unit,
): UIState
// UI event entry point
fun onUiEvent(uiEvent: UIEvent) =
viewModelScope.launch { _uiEvents.send(uiEvent) }
private fun launchUiEffect(uiEffect: UIEffect){
if(!_uiEffects.tryEmit(uiEffect)) {
error("SettingsViewModel: UI effect buffer overflow.")
}
}
}
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val purgeRepository: PurgeRepository,
private val userPreferencesRepository: UserPreferencesRepository,
): MoleculeViewModel<SettingsUIEvent, SettingsUIState, SettingsUIEffect>(
initialState = with(UserPreferences()){
SettingsUIState(
clearCacheEnabled = true,
highContrastEnabled = true,
dropdownMenuState = SettingsDropdownMenuState.None,
alertDialogState = SettingsAlertDialogState.None,
darkMode = darkMode,
colorPalette = colorPalette,
highContrast = highContrast,
imageQuality = imageQuality,
typography = typography,
)
}
) {
@Composable
override fun uiState(
initialState: SettingsUIState,
uiEvents: Flow<SettingsUIEvent>,
launchUiEffect: (SettingsUIEffect) -> Unit,
): SettingsUIState = settingsUIState(
initialState = initialState,
uiEvents = uiEvents,
launchUiEffect = launchUiEffect,
purgeRepository = purgeRepository,
userPreferencesRepository = userPreferencesRepository
)
}
// Compose UI
@Composable
fun settingsUIState(
initialState: SettingsUIState,
uiEvents: Flow<SettingsUIEvent>,
launchUiEffect: (SettingsUIEffect) -> Unit,
purgeRepository: PurgeRepository,
userPreferencesRepository: UserPreferencesRepository,
): SettingsUIState {
var clearCacheEnabled by remember {
mutableStateOf(initialState.clearCacheEnabled)
}
var dropdownMenuState by remember {
mutableStateOf(initialState.dropdownMenuState)
}
var alertDialogState by remember {
mutableStateOf(initialState.alertDialogState)
}
val userPreferences by remember {
userPreferencesRepository.userPreferences
}.collectAsState(
UserPreferences(
darkMode = initialState.darkMode,
colorPalette = initialState.colorPalette,
highContrast = initialState.highContrast,
imageQuality = initialState.imageQuality,
typography = initialState.typography,
)
)
LaunchedEffect(Unit) {
uiEvents.collect { uiEvent ->
when(uiEvent) {
ClickClearCache -> {
// Disable clear cache button until state is recreated
clearCacheEnabled = false
launch(Dispatchers.IO) {
purgeRepository.purgeCache()
launchUiEffect(CachePurged)
}
}
is SelectDarkMode -> { /* .. */ }
// ...25 more UI events
}
}
}
val highContrastEnabled =
userPreferences.colorPalette != SYSTEM_DYNAMIC
return SettingsUIState(
clearCacheEnabled = clearCacheEnabled,
dropdownMenuState = dropdownMenuState,
alertDialogState = alertDialogState,
highContrastEnabled = highContrastEnabled,
darkMode = userPreferences.darkMode,
colorPalette = userPreferences.colorPalette,
typography = userPreferences.typography,
imageQuality = userPreferences.imageQuality,
highContrast =
if(highContrastEnabled) userPreferences.highContrast
else HighContrast.OFF,
)
}
// Compose UI
@Composable
fun SettingsRoute(
navigateToTipsAndInfo: () -> Unit,
navigateToStatistics: () -> Unit,
settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val uriHandler = LocalUriHandler.current
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
settingsViewModel.uiEffect.collect{ uiEvent ->
when(uiEvent){
AllDataDeleted -> { /* toast */ }
CachePurged -> { /* toast */ }
RateAndReviewRequest -> inAppReviewRequest()
is Navigation -> when(uiEvent.dest) {
Statistics -> navigateToStatistics()
TipsAndInfo -> navigateToTipsAndInfo()
is Web -> uriHandler.openUri(uiEvent.dest.url)
}
}
}
}
}
val uiState by settingsViewModel
.uiState
.collectAsStateWithLifecycle()
SettingsScreen(
uiState = uiState,
onUiEvent = settingsViewModel::onUiEvent
)
}
@Composable
fun SettingsScreen(
uiState: SettingsUIState,
onUiEvent: (SettingsUIEvent) -> Unit,
) {
// Convert SettingsUIState to Compose UI
// and report UI events via onUiEvent()
}
// Compose UI Preview
@Preview
@Composable
fun PreviewSettingsScreen() = NoopTheme {
Surface {
SettingsScreen(
uiState = SettingsUIState(
alertDialogState = SettingsAlertDialogState.None,
dropdownMenuState = SettingsDropdownMenuState.None,
highContrastEnabled = true,
clearCacheEnabled = true,
darkMode = DARK,
colorPalette = ROAD_WARRIOR,
highContrast = OFF,
imageQuality = STANDARD,
typography = DEFAULT,
),
onUiEvent = {},
)
}
}
// Compose state management test
class SettingsUIStateTest {
@get:Rule val mockkRule = MockKRule(this)
@get:Rule val dispatcherRule = MainDispatcherRule()
@MockK lateinit var purgeRepository: PurgeRepository
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository
val testInitialUserPreferences =
UserPreferences(imageQuality = VERY_HIGH)
val testInitialUiState = with(testInitialUserPreferences){
SettingsUIState(
clearCacheEnabled = true,
highContrastEnabled = true,
dropdownMenuState = SettingsDropdownMenuState.None,
alertDialogState = SettingsAlertDialogState.None,
darkMode = darkMode,
colorPalette = colorPalette,
highContrast = highContrast,
imageQuality = imageQuality,
typography = typography,
)
}
@Before
fun beforeEach() = runTest {
every { userPreferencesRepository.userPreferences }
returns flowOf(testInitialUserPreferences)
justRun { purgeRepository.purgeCache() }
}
@Test
fun `Clear cache disables button and triggers effect`() = runTest {
val events = Channel<SettingsUIEvent>()
val uiEffects = ArrayList<SettingsUIEffect>()
moleculeFlow(mode = Immediate){
settingsUIState(
initialState = testInitialUiState,
events.receiveAsFlow(),
{ uiEffects.add(it) },
purgeRepository = purgeRepository,
userPreferencesRepository = userPreferencesRepository,
)
}.test {
assertTrue(awaitItem().clearCacheEnabled)
events.send(ClickClearCache)
assertFalse(awaitItem().clearCacheEnabled)
assertEquals(1, uiEffects.size)
assertEquals(CachePurged, uiEffects[0])
uiEffects.clear()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment