Last active
September 17, 2024 16:46
-
-
Save Lucodivo/ed2b2838bf10fd658ddcb4d8062e2eed to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 1
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
// 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() { | |
// UI state enums | |
enum class DropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class AlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
data class PreferencesState( | |
// all fields are simple enums defined elsewhere | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
sealed interface NavigationState { | |
data object TipsAndInfo: NavigationState | |
data object Statistics: NavigationState | |
data class Web(val url: String): NavigationState | |
} | |
// UI state | |
// Note: In Merlinsbag, Event is/was a simple way of | |
// sending transient state (ex: showing Toasts, displaying | |
// Snackbar message, navigation events, in-app review requests) | |
var cachePurged by mutableStateOf(Event<Unit>(null)) | |
var dataDeleted by mutableStateOf(Event<Unit>(null)) | |
var rateAndReviewRequest by mutableStateOf(Event<Unit>(null)) | |
var navigationEventState by mutableStateOf(Event<NavigationState>(null)) | |
var clearCacheEnabled by mutableStateOf(true) | |
var highContrastEnabled by mutableStateOf(true) | |
var dropdownMenuState by mutableStateOf(DropdownMenuState.None) | |
var alertDialogState by mutableStateOf(AlertDialogState.None) | |
val preferencesState = userPreferencesRepository.userPreferences.map { | |
PreferencesState( | |
darkMode = it.darkMode, | |
colorPalette = it.colorPalette, | |
highContrast = | |
if(it.colorPalette != SYSTEM_DYNAMIC) it.highContrast | |
else HighContrast.OFF, | |
imageQuality = it.imageQuality, | |
typography = it.typography, | |
) | |
}.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = with(UserPreferences()) { | |
PreferencesState( | |
darkMode = darkMode, | |
colorPalette = colorPalette, | |
highContrast = highContrast, | |
imageQuality = imageQuality, | |
typography = typography, | |
) | |
}, | |
) | |
// UI events | |
fun onClickClearCache() { | |
// Disable clear cache button until viewmodel is recreated | |
clearCacheEnabled = false | |
viewModelScope.launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
cachePurged = Event(Unit) | |
} | |
} | |
fun onClickDarkMode() { /* .. */ } | |
// ...25 more UI event methods | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(settingsViewModel.cachePurged) { | |
settingsViewModel.cachePurged.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(settingsViewModel.dataDeleted) { | |
settingsViewModel.dataDeleted.getContentIfNotHandled()?.let { | |
/* toast */ | |
} | |
} | |
LaunchedEffect(settingsViewModel.rateAndReviewRequest) { | |
settingsViewModel.rateAndReviewRequest.getContentIfNotHandled()?.let { | |
inAppReviewRequest() | |
} | |
} | |
LaunchedEffect(settingsViewModel.navigationEventState) { | |
settingsViewModel.navigationEventState.getContentIfNotHandled()?.let { | |
when(it) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(it.url) | |
} | |
} | |
} | |
val userPreferences by settingsViewModel | |
.preferencesState | |
.collectAsStateWithLifcycle() | |
SettingsScreen( | |
alertDialogState = settingsViewModel.alertDialogState, | |
dropdownMenuState = settingsViewModel.dropdownMenuState, | |
highContrastEnabled = settingsViewModel.highContrastEnabled, | |
clearCacheEnabled = settingsViewModel.clearCacheEnabled, | |
preferencesState = userPreferences, | |
onClickClearCache = settingsViewModel::onClickClearCache, | |
onClickDarkMode = settingsViewModel::onClickDarkMode, | |
// ...25 more UI event lambdas | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
alertDialogState: AlertDialogState, | |
dropdownMenuState: DropdownMenuState, | |
highContrastEnabled: Boolean, | |
clearCacheEnabled: Boolean, | |
preferencesState: PreferencesState, | |
onClickClearCache: () -> Unit, | |
onClickDarkMode: () -> Unit, | |
// ...25 more UI event lambdas | |
) { | |
// Convert state to Compose UI and | |
// report UI events to viewmodel via lambda arguments… | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = MerlingsbagTheme { | |
Surface { | |
SettingsScreen( | |
alertDialogState = AlertDialogState.None, | |
dropdownMenuState = DropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
preferencesState = PreferencesState( | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onClickClearCache = {}, | |
onClickDarkMode = {}, | |
// ...25 more UI event lambdas | |
) | |
} | |
} | |
// 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 | |
@Before | |
fun setup() { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(UserPreferences()) | |
viewModel = SettingsViewModel( | |
purgeRepository, | |
userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Selecting a higher image quality triggers alert dialog`() = runTest { | |
val collectJob = launch(UnconfinedTestDispatcher()) { | |
viewModel.preferencesState.collect() | |
} | |
viewModel.onSelectedImageQuality( | |
ImageQuality.entries[testInitialUserPreferences.imageQuality.ordinal + 1] | |
) | |
assertEquals( | |
AlertDialogState.ImageQuality, | |
viewModel.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