Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Lucodivo/c50575df093559dfc9ee5ad54ef7a556 to your computer and use it in GitHub Desktop.
Save Lucodivo/c50575df093559dfc9ee5ad54ef7a556 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 2
// 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