Skip to content

Instantly share code, notes, and snippets.

@Lucodivo
Last active September 17, 2024 16:47
Show Gist options
  • Save Lucodivo/b305f9dc7be59c9ebe9e8a1d7ab64b83 to your computer and use it in GitHub Desktop.
Save Lucodivo/b305f9dc7be59c9ebe9e8a1d7ab64b83 to your computer and use it in GitHub Desktop.
ViewModel to UI communication: Part 3 - UI Events & UI Effects
// 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