Last active
September 17, 2024 16:49
-
-
Save Lucodivo/85669d07e3bea9caa6d99cf6a94e08d1 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 4.1 - stateIn() fix
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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