Vivek Modi
05/01/2025, 8:35 PMJetpack Compose screen that fetches contact data using a ContactViewModel. The ViewModel uses StateFlow, performs API calls through a ContactsApi, and includes email validation logic using a UseCase. It all works, but I want to refactor this to align more with Kotlin best practices, Jetpack architecture guidelines, and better separation of concerns.Vivek Modi
05/01/2025, 8:35 PMContactViewModel and UI code:Vivek Modi
05/01/2025, 8:35 PMsetupEmailValidation) on a button click.
3. The initial fetch is encapsulated in a Flow and combined using combine(...).
4. I'm using `MutableStateFlow`for booleans and loading indicators, which feels a bit ad-hoc.
5. The TextFieldState is tightly coupled to the ViewModel rather than being passed from UI.Vivek Modi
05/01/2025, 8:35 PMVivek Modi
05/01/2025, 8:35 PMStateFlows this way?
3. Should the email validation logic live inside the ViewModel or be moved to the UI layer using LaunchedEffect?
4. Are there recommended improvements for separating UI logic from state-holding logic in Compose?Vivek Modi
05/01/2025, 8:35 PMVivek Modi
05/01/2025, 8:35 PMclass ContactViewModel(
private val contactsApi: ContactsApi,
private val ioDispatcher: CoroutineDispatcher,
private val validateEmailUseCase: ValidateEmailUseCase,
) : ViewModel() {
private val loadingState = MutableStateFlow(true)
private val isEmailAddressValid = MutableStateFlow(false)
private val showAddContactDialog = MutableStateFlow(false)
private val emailAddressState = TextFieldState()
private val contactUiState = ContactUiState(isLoading = true, email = emailAddressState)
private val initialContactsFetchFlow: Flow<ContactUiState> = flow {
contactsApi.getContacts().also {
loadingState.update { false }
}.fold(
onSuccess = {
emit(contactUiState.copy(users = it.contacts))
},
onFailure = {
emit(contactUiState)
}
)
}
val uiState = combine(
initialContactsFetchFlow,
loadingState,
showAddContactDialog,
isEmailAddressValid,
) { contacts, isLoading, showAddContactDialog, isValidAddress ->
contactUiState.copy(
users = contacts.users,
isLoading = isLoading,
isEmailInvalid = isValidAddress,
showAddContactDialog = showAddContactDialog,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = contactUiState
)
fun onAction(action: ContactUiEvent) {
when (action) {
ContactUiEvent.OnAddContactClicked -> {
updateShowAddContactDialog(true)
setupEmailValidation()
}
ContactUiEvent.OnDismissDialogClicked -> {
updateShowAddContactDialog(false)
}
}
}
private fun updateShowAddContactDialog(showDialog: Boolean) {
viewModelScope.launch {
showAddContactDialog.update { showDialog }
}
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private fun setupEmailValidation() {
viewModelScope.launch {
snapshotFlow { emailAddressState.text }
.flowOn(ioDispatcher)
.debounce(5.milliseconds)
.filter { it.isNotBlank() }
.mapLatest { validateEmailUseCase(it.toString()) }
.collect { isValidAddress ->
isEmailAddressValid.update { !isValidAddress }
}
}
}
}Vivek Modi
05/01/2025, 8:35 PMVivek Modi
05/01/2025, 8:35 PM@Composable
fun ContactScreenRoute(
onBackPress: () -> Unit,
viewModel: ContactViewModel = koinViewModel(),
) {
val contactUiState by viewModel.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = onBackPress)
ContactScreen(
uiState = contactUiState,
onAction = viewModel::onAction
)
}
@Composable
fun ContactScreen(
uiState: ContactUiState,
onAction: (ContactUiEvent) -> Unit,
) {
val contentPadding = LocalScaffoldPadding.current.paddingValues
if (uiState.showAddContactDialog) {
AddContactEmailDialog(
uiState = uiState,
onConfirmation = {},
onDismissRequest = {
onAction(ContactUiEvent.OnDismissDialogClicked)
}
)
}
LoadingOverlay(uiState.isLoading) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(
horizontal = MaterialTheme.spacing.spaceMedium,
vertical = MaterialTheme.spacing.spaceMedium
)
.consumeWindowInsets(contentPadding)
.imePadding(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.spaceSmall),
contentPadding = contentPadding
) {
item {
Text(
text = stringResource(R.string.contact_header),
style = MaterialTheme.typography.headlineMedium
)
}
item {
if (uiState.isUsersEmpty) {
Column(
modifier = Modifier.fillParentMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.no_contact_yet_header),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Medium,
color = Coal
)
)
AnotherTripSpacer(modifier = Modifier.height(MaterialTheme.spacing.spaceSmall))
Text(
text = stringResource(R.string.no_contact_yet_description),
style = MaterialTheme.typography.bodyMedium
)
AnotherTripSpacer(modifier = Modifier.height(MaterialTheme.spacing.spaceSemiLarge))
AnotherTripPrimaryButton(
textResourceId = R.string.add_contact,
isFullWidth = false,
iconResId = Icons.AutoMirrored.Rounded.ArrowForward,
onClick = {
onAction(ContactUiEvent.OnAddContactClicked)
}
)
}
}
}
}
}
}
@Composable
fun AddContactEmailDialog(
uiState: ContactUiState,
onDismissRequest: () -> Unit,
onConfirmation: () -> Unit,
) {
AlertDialog(
icon = {
Icon(
imageVector = Icons.Default.Email,
contentDescription = "Example Icon",
tint = MaterialTheme.colorScheme.primary
)
},
title = {
Text(text = stringResource(R.string.add_your_email))
},
text = {
AnotherTripTextField(
state = uiState.email,
label = stringResource(R.string.email_placeholder),
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
error = uiState.isEmailInvalid
)
},
onDismissRequest = {
onDismissRequest()
},
confirmButton = {
TextButton(
onClick = {
onConfirmation()
}
) {
Text(stringResource(R.string.send_request))
}
},
dismissButton = {
TextButton(
enabled = uiState.isEmailInvalid,
onClick = {
onDismissRequest()
}
) {
Text(stringResource(R.string.dismiss))
}
}
)
}Vivek Modi
05/01/2025, 8:36 PMdata class ContactUiState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val showAddContactDialog: Boolean = false,
val email: TextFieldState,
val isEmailInvalid: Boolean = false,
) {
val isUsersEmpty: Boolean
get() = users.isEmpty()
}Vivek Modi
05/01/2025, 8:36 PMVivek Modi
05/01/2025, 8:36 PMinterface ContactsApi {
suspend fun getContacts(): Result<ContactsApiModel>
suspend fun sendContacts(contact: SendRequest): Result<Unit>
}