Last active
September 17, 2024 16:47
-
-
Save Lucodivo/b305f9dc7be59c9ebe9e8a1d7ab64b83 to your computer and use it in GitHub Desktop.
ViewModel to UI communication: Part 3 - UI Events & UI Effects
This file contains 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 | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): ViewModel() { | |
private data class LocallyManagedState ( | |
val clearCacheEnabled: Boolean = true, | |
val dropdownMenuState: SettingsDropdownMenuState = | |
SettingsDropdownMenuState.None, | |
val alertDialogState: SettingsAlertDialogState = | |
SettingsAlertDialogState.None, | |
) | |
private val locallyManagedState = MutableStateFlow(LocallyManagedState()) | |
private fun highContrastIsEnabled(colorPalette: ColorPalette) = | |
colorPalette != SYSTEM_DYNAMIC | |
private val _uiEffect = | |
MutableSharedFlow<SettingsUIEffect>(extraBufferCapacity = 20) | |
// UI state & effect providers | |
val uiEffect: SharedFlow<SettingsUIEffect> = _uiEffect | |
val uiState: StateFlow<SettingsUIState> = combine( | |
userPreferencesRepository.userPreferences, | |
locallyManagedState, | |
) { userPreferences, lms -> | |
// System dynamic color schemes do not currently support high contrast | |
val highContrastEnabled = | |
highContrastIsEnabled(userPreferences.colorPalette) | |
SettingsUIState( | |
clearCacheEnabled = lms.clearCacheEnabled, | |
dropdownMenuState = lms.dropdownMenuState, | |
alertDialogState = lms.alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
imageQuality = userPreferences.imageQuality, | |
typography = userPreferences.typography, | |
) | |
}.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = with(locallyManagedState.value){ | |
val defaultPreferences = UserPreferences() | |
SettingsUIState( | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = | |
highContrastIsEnabled(defaultPreferences.colorPalette), | |
darkMode = defaultPreferences.darkMode, | |
colorPalette = defaultPreferences.colorPalette, | |
highContrast = defaultPreferences.highContrast, | |
imageQuality = defaultPreferences.imageQuality, | |
typography = defaultPreferences.typography, | |
) | |
}, | |
) | |
// UI event entry point | |
fun onUiEvent(uiEvent: SettingsUIEvent) { | |
when(event){ | |
ClickClearCache -> { | |
// Disable clearing cache until viewmodel is recreated | |
locallyManagedState.value = | |
locallyManagedState.value.copy(clearCacheEnabled = false) | |
viewModelScope.launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
launchUiEvent(CachePurged) | |
} | |
} | |
is SelectDarkMode -> { /* .. */ } | |
// ...25 more UI events | |
} | |
} | |
private fun launchUiEffect(uiEffect: SettingsUIEffect){ | |
if(!_uiEffect.tryEmit(uiEffect)) { | |
error("SettingsViewModel: UI effect buffer overflow.") | |
} | |
} | |
} | |
// 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{ uiEffect -> | |
when(uiEffect){ | |
AllDataDeleted -> { /* toast */ } | |
CachePurged -> { /* toast */ } | |
RateAndReviewRequest -> inAppReviewRequest() | |
is Navigation -> when(uiEffect.dest) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(uiEffect.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() = MerlinsbagTheme { | |
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 = {}, | |
) | |
} | |
} | |
// ViewModel tests | |
class SettingsViewModelTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
lateinit var viewModel: SettingsViewModel | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
viewModel = SettingsViewModel( | |
purgeRepository, | |
userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Click clear cache triggers UI event`() = runTest { | |
val collectJob = launch(UnconfinedTestDispatcher()) { | |
viewModel.uiState.collect() | |
} | |
viewModel.uiEffect.test { | |
viewModel.onUiEvent(ClickClearCache) | |
assertEquals(CachePurged, awaitItem()) | |
assertFalse(viewModel.uiState.value.clearCacheEnabled) | |
} | |
collectJob.cancel() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment