Last active
September 17, 2024 16:47
-
-
Save Lucodivo/c50575df093559dfc9ee5ad54ef7a556 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 2
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, | |
} | |
sealed interface SettingsNavigationState { | |
data object TipsAndInfo: SettingsNavigationState | |
data object Statistics: SettingsNavigationState | |
data class Web(val url: String): SettingsNavigationState | |
} | |
// UI state | |
data class SettingsUIState ( | |
val cachePurged: Event<Unit>, | |
val dataDeleted: Event<Unit>, | |
val rateAndReviewRequest: Event<Unit>, | |
val navigationEventState: Event<SettingsNavigationState>, | |
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 events | |
interface SettingsUIEventListener { | |
fun onClickClearCache() | |
fun onClickDarkMode() | |
// ...25 more UI event methods | |
} | |
// 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(), SettingsUIEventListener { | |
private data class LocallyManagedState ( | |
val cachePurged: Event<Unit> = Event(null), | |
val dataDeleted: Event<Unit> = Event(null), | |
val rateAndReviewRequest: Event<Unit> = Event(null), | |
val navigationEventState: Event<SettingsNavigationState> = Event(null), | |
val clearCacheEnabled: Boolean = true, | |
val dropdownMenuState: SettingsDropdownMenuState = | |
SettingsDropdownMenuState.None, | |
val alertDialogState: SettingsAlertDialogState = | |
SettingsAlertDialogState.None, | |
) | |
private val locallyManagedState = MutableStateFlow(LocallyManagedState()) | |
// System dynamic color schemes do not currently support high contrast | |
private fun highContrastIsEnabled(colorPalette: ColorPalette) = | |
colorPalette != SYSTEM_DYNAMIC | |
// UI state provider | |
val uiState: StateFlow<SettingsUIState> = combine( | |
userPreferencesRepository.userPreferences, | |
locallyManagedState, | |
) { userPreferences, lms -> | |
val highContrastEnabled = | |
highContrastIsEnabled(userPreferences.colorPalette) | |
cachedImageQuality = userPreferences.imageQuality | |
SettingsUIState( | |
cachePurged = lms.cachePurged, | |
dataDeleted = lms.dataDeleted, | |
rateAndReviewRequest = lms.rateAndReviewRequest, | |
navigationEventState = lms.navigationEventState, | |
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( | |
cachePurged = cachePurged, | |
dataDeleted = dataDeleted, | |
rateAndReviewRequest = rateAndReviewRequest, | |
navigationEventState = navigationEventState, | |
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 overrides | |
override fun onClickClearCache() { | |
locallyManagedState.value = | |
locallyManagedState.value.copy(clearCacheEnabled = false) | |
viewModelScope.launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
locallyManagedState.value = | |
locallyManagedState.value.copy(cachePurged = Event(Unit)) | |
} | |
} | |
override fun onClickDarkMode() { /* .. */ } | |
// ...25 more UI event override methods | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val uriHandler = LocalUriHandler.current | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
LaunchedEffect(uiState.cachePurged) { | |
uiState.cachePurged.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(uiState.dataDeleted) { | |
uiState.dataDeleted.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(uiState.rateAndReviewRequest) { | |
uiState.rateAndReviewRequest.getContentIfNotHandled()?.let { | |
inAppReviewRequest() | |
} | |
} | |
LaunchedEffect(uiState.navigationEventState) { | |
uiState.navigationEventState.getContentIfNotHandled()?.let { | |
when(it){ | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(it.url) | |
} | |
} | |
} | |
SettingsScreen( | |
uiState = uiState, | |
uiEventListener = settingsViewModel | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
uiEventListener: SettingsUIEventListener, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report user actions to SettingsUIEventListener | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = MerlinsbagTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
cachePurged = Event(null), | |
dataDeleted = Event(null), | |
navigationEventState = Event(null), | |
rateAndReviewRequest = Event(null), | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
uiStateChanger = object: SettingsUIEventListener { | |
override fun onClickClearCache() {} | |
override fun onClickDarkMode() {} | |
// ...25 more UI event override methods | |
}, | |
) | |
} | |
} | |
// 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) | |
viewModel = SettingsViewModel( | |
purgeRepository, | |
userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Selecting a higher image quality triggers alert dialog`() = runTest { | |
val collectJob = launch(UnconfinedTestDispatcher()) { | |
viewModel.uiState.collect() | |
} | |
viewModel.onSelectedImageQuality( | |
ImageQuality.entries[ | |
testInitialUserPreferences.imageQuality.ordinal + 1 | |
] | |
) | |
assertEquals( | |
SettingsAlertDialogState.ImageQuality, | |
viewModel.uiState.value.alertDialogState | |
) | |
collectJob.cancel() | |
} | |
} | |
// Event | |
open class Event<out T>(private val content: T?) { | |
var hasBeenHandled = false | |
private set | |
fun getContentIfNotHandled(): T? { | |
return if(!hasBeenHandled) { | |
hasBeenHandled = true | |
content | |
} else null | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment