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>
}