Skip to content

Instantly share code, notes, and snippets.

@Lucodivo
Last active September 17, 2024 16:46
Show Gist options
  • Save Lucodivo/ed2b2838bf10fd658ddcb4d8062e2eed to your computer and use it in GitHub Desktop.
Save Lucodivo/ed2b2838bf10fd658ddcb4d8062e2eed to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 1
// 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