Hi I'm working on a `Jetpack Compose` screen that ...
# codereview
v
Hi I'm working on a
Jetpack 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.
Here’s a simplified version of the current
ContactViewModel
and UI code:
1. The ViewModel mixes UI state and business logic. 2. Email validation is directly triggered via a function (
setupEmailValidation
) 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.
Specific questions:
1. How can I better structure the state flows so they’re more testable and scalable? 2. s it a good practice to create a single uiState by combining many independent
StateFlows
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?
Any insights on improving this structure while adhering to modern Android architectural best practices (UDF/MVI/MVVM) would be appreciated.
ContactViewModel
Copy code
class 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 }
                }
        }
    }
}
ContactScreenRoute
Copy code
@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))
            }
        }
    )
}
ContactUiState
Copy code
data 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()
}
ContactsApi
Copy code
interface ContactsApi {
    suspend fun getContacts(): Result<ContactsApiModel>
    suspend fun sendContacts(contact: SendRequest): Result<Unit>
}