From 585c0a9a4bee6f71eedfa7862b330d4f4fef29b9 Mon Sep 17 00:00:00 2001 From: Nagarjuna Date: Fri, 29 Aug 2025 15:32:04 +0530 Subject: [PATCH] refactor(loan-application): dialogs --- .../confirmDetails/ConfirmDetailsScreen.kt | 67 ++-- .../confirmDetails/ConfirmDetailsViewModel.kt | 31 +- .../loanApplication/LoanApplyScreen.kt | 287 ++++++++------- .../loanApplication/LoanApplyViewModel.kt | 344 ++++++++---------- .../LoanProductDetailsScren.kt | 201 +++++----- .../LoanProductDetailsViewModel.kt | 77 ++-- .../loanType/SelectLoanTypeSceen.kt | 117 +++--- .../loanType/SelectLoanTypeViewModel.kt | 99 +++-- 8 files changed, 662 insertions(+), 561 deletions(-) diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsScreen.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsScreen.kt index 905e2d319c..2776a5ea21 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsScreen.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsScreen.kt @@ -37,10 +37,12 @@ import org.mifos.mobile.core.designsystem.component.MifosElevatedScaffold import org.mifos.mobile.core.designsystem.theme.DesignToken import org.mifos.mobile.core.designsystem.theme.MifosTypography import org.mifos.mobile.core.ui.component.MifosDetailsCard +import org.mifos.mobile.core.ui.component.MifosErrorComponent import org.mifos.mobile.core.ui.component.MifosPoweredCard import org.mifos.mobile.core.ui.component.MifosProgressIndicator import org.mifos.mobile.core.ui.component.MifosProgressIndicatorOverlay import org.mifos.mobile.core.ui.utils.EventsEffect +import org.mifos.mobile.core.ui.utils.ScreenUiState @Composable internal fun ConfirmDetailsScreen( @@ -99,10 +101,6 @@ internal fun ConfirmDetailsDialog( ) } - ConfirmDetailsDialogState.Loading -> MifosProgressIndicator() - - ConfirmDetailsDialogState.OverlayLoading -> MifosProgressIndicatorOverlay() - null -> {} } } @@ -126,27 +124,52 @@ internal fun ConfirmDetailsScreenContent( } }, ) { - Column( - modifier = modifier - .padding(DesignToken.padding.large) - .padding(top = DesignToken.padding.medium) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.extraLarge), - ) { - MifosDetailsCard(state.details) + when (state.uiState) { + is ScreenUiState.Error -> { + MifosErrorComponent( + isRetryEnabled = false, + message = stringResource(state.uiState.message), + ) + } + + ScreenUiState.Loading -> MifosProgressIndicator() - MifosButton( - modifier = Modifier.fillMaxWidth().height(DesignToken.sizes.inputHeight), - onClick = { - onAction(ConfirmDetailsAction.NavigateToAuthenticate) - }, - shape = DesignToken.shapes.medium, - ) { - Text( - text = stringResource(Res.string.feature_apply_loan_title), - style = MifosTypography.titleMedium, + ScreenUiState.Network -> { + MifosErrorComponent( + isNetworkConnected = false, + isRetryEnabled = false, ) } + + ScreenUiState.Success -> { + Column( + modifier = modifier + .padding(DesignToken.padding.large) + .padding(top = DesignToken.padding.medium) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.extraLarge), + ) { + MifosDetailsCard(state.details) + + MifosButton( + modifier = Modifier.fillMaxWidth().height(DesignToken.sizes.inputHeight), + onClick = { + onAction(ConfirmDetailsAction.NavigateToAuthenticate) + }, + shape = DesignToken.shapes.medium, + ) { + Text( + text = stringResource(Res.string.feature_apply_loan_title), + style = MifosTypography.titleMedium, + ) + } + } + + if (state.showOverlay) { + MifosProgressIndicatorOverlay() + } + } + else -> { } } } } diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsViewModel.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsViewModel.kt index 66d8f01d5c..405c229349 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsViewModel.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/confirmDetails/ConfirmDetailsViewModel.kt @@ -27,6 +27,7 @@ import mifos_mobile.feature.loan_application.generated.resources.feature_apply_l import mifos_mobile.feature.loan_application.generated.resources.feature_apply_loan_status_success import mifos_mobile.feature.loan_application.generated.resources.feature_apply_loan_status_success_action import mifos_mobile.feature.loan_application.generated.resources.feature_apply_loan_status_success_tip +import okio.IOException import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.mifos.mobile.core.common.DataState @@ -41,6 +42,7 @@ import org.mifos.mobile.core.model.enums.LoanState import org.mifos.mobile.core.ui.utils.AuthResult import org.mifos.mobile.core.ui.utils.BaseViewModel import org.mifos.mobile.core.ui.utils.ResultNavigator +import org.mifos.mobile.core.ui.utils.ScreenUiState import org.mifos.mobile.core.ui.utils.observe /** @@ -122,14 +124,14 @@ internal class ConfirmDetailsViewModel( */ @Suppress("UnusedPrivateMember") private fun showLoading() { - updateState { it.copy(dialogState = ConfirmDetailsDialogState.Loading) } + updateState { it.copy(uiState = ScreenUiState.Loading) } } /** * Sets the dialog state to an overlay loading spinner. */ private fun showOverlayLoading() { - updateState { it.copy(dialogState = ConfirmDetailsDialogState.OverlayLoading) } + updateState { it.copy(showOverlay = !state.showOverlay) } } /** @@ -137,6 +139,7 @@ internal class ConfirmDetailsViewModel( * * @param error The [StringResource] for the error message to display. */ + @Suppress("UnusedPrivateMember") private fun showErrorDialog(error: StringResource) { updateState { it.copy(dialogState = ConfirmDetailsDialogState.Error(error)) } } @@ -230,13 +233,23 @@ internal class ConfirmDetailsViewModel( is DataState.Success -> { updateState { it.copy( + showOverlay = false, loanTemplate = template.data, ) } sendAction(ConfirmDetailsAction.Internal.ApplyLoan) } is DataState.Error -> { - showErrorDialog(Res.string.feature_apply_loan_error_server) + updateState { + it.copy( + showOverlay = false, + uiState = if (template.exception is IOException) { + ScreenUiState.Network + } else { + ScreenUiState.Error(Res.string.feature_apply_loan_error_server) + }, + ) + } } } } @@ -268,6 +281,9 @@ internal class ConfirmDetailsViewModel( private suspend fun handleLoanApplyStatus(status: DataState) { when (status) { is DataState.Error -> { + updateState { + it.copy(showOverlay = false) + } sendEvent( ConfirmDetailsEvent.NavigateToStatus( eventType = EventType.FAILURE.name, @@ -343,7 +359,10 @@ internal data class ConfirmDetailsState( val principalAmount: String, val details: Map = emptyMap(), val loanTemplate: LoanTemplate? = null, + + val showOverlay: Boolean = false, val dialogState: ConfirmDetailsDialogState? = null, + val uiState: ScreenUiState? = ScreenUiState.Success, ) /** @@ -435,12 +454,6 @@ sealed interface ConfirmDetailsAction { * shown on the confirm details screen. */ internal sealed interface ConfirmDetailsDialogState { - /** Represents an overlay loading state. */ - data object OverlayLoading : ConfirmDetailsDialogState - - /** Represents a full-screen loading state. */ - data object Loading : ConfirmDetailsDialogState - /** * Represents a generic error dialog with a message. * @property message The [StringResource] for the error message. diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyScreen.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyScreen.kt index 7683f8f0c5..48b090ef7b 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyScreen.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SelectableDates import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -63,6 +64,7 @@ import org.mifos.mobile.core.ui.component.MifosOutlineDropdown import org.mifos.mobile.core.ui.component.MifosPoweredCard import org.mifos.mobile.core.ui.component.MifosProgressIndicator import org.mifos.mobile.core.ui.utils.EventsEffect +import org.mifos.mobile.core.ui.utils.ScreenUiState @Composable internal fun LoanApplyScreen( @@ -97,7 +99,6 @@ internal fun LoanApplyScreen( ) LoanAccountDialog( - state = state, dialogState = state.loanApplicationDialogState, onAction = remember(viewModel) { { viewModel.trySendAction(it) } @@ -108,7 +109,6 @@ internal fun LoanApplyScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LoanAccountDialog( - state: LoanApplicationState, dialogState: LoanApplicationDialogState?, onAction: (LoanApplicationAction) -> Unit, ) { @@ -122,59 +122,6 @@ internal fun LoanAccountDialog( ) } - LoanApplicationDialogState.Loading -> MifosProgressIndicator() - - LoanApplicationDialogState.OverlayLoading -> MifosProgressIndicator() - - is LoanApplicationDialogState.ShowDatePicker -> { - val today = Clock.System.now().toEpochMilliseconds() - val activationMillis = DateHelper.getDateAsLongFromList( - DateHelper.getDateAsList(state.activationDate), - ) ?: 0L - - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = state.currentDate, - selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - return utcTimeMillis in activationMillis..today - } - }, - ) - - DatePickerDialog( - onDismissRequest = { - onAction(LoanApplicationAction.ToggleDatePicker) - }, - confirmButton = { - TextButton( - onClick = { - onAction(LoanApplicationAction.ToggleDatePicker) - datePickerState.selectedDateMillis?.let { - onAction( - LoanApplicationAction.DisbursementDateChange( - DateHelper.getDateMonthYearString(it), - ), - ) - } - }, - ) { - Text(stringResource(Res.string.feature_apply_loan_button_ok)) - } - }, - dismissButton = { - TextButton( - onClick = { - onAction(LoanApplicationAction.ToggleDatePicker) - }, - ) { - Text(stringResource(Res.string.feature_apply_loan_button_cancel)) - } - }, - ) { - DatePicker(state = datePickerState) - } - } - is LoanApplicationDialogState.UnsavedChanges -> { MifosBasicDialog( visibilityState = BasicDialogState.Shown( @@ -185,18 +132,11 @@ internal fun LoanAccountDialog( ) } - is LoanApplicationDialogState.Network -> { - MifosErrorComponent( - isNetworkConnected = state.networkStatus, - isRetryEnabled = true, - onRetry = { onAction(LoanApplicationAction.Retry) }, - ) - } - null -> {} } } +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LoanAccountContent( state: LoanApplicationState, @@ -216,93 +156,164 @@ internal fun LoanAccountContent( } }, ) { - if (state.loanApplicationDialogState == null) { - Column( - modifier = Modifier - .padding(DesignToken.padding.large) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.large), - ) { - MifosOutlinedTextField( - value = state.applicantName, - onValueChange = { onAction(LoanApplicationAction.ApplicantNameChange(it)) }, - label = stringResource(Res.string.feature_apply_loan_label_applicant_name), - shape = DesignToken.shapes.medium, - textStyle = MifosTypography.bodyLarge, - config = MifosTextFieldConfig( - isError = state.applicantNameError != null, - errorText = state.applicantNameError?.let { stringResource(it) }, - ), + when (state.uiState) { + is ScreenUiState.Error -> { + MifosErrorComponent( + isRetryEnabled = true, + message = stringResource(state.uiState.message), + onRetry = { onAction(LoanApplicationAction.Retry) }, ) + } - MifosOutlineDropdown( - selectedText = state.loanProductName, - items = emptyMap(), - enabled = false, - onItemSelected = { _, _ -> }, - label = stringResource(Res.string.feature_apply_loan_label_loan_product), - ) + ScreenUiState.Loading -> MifosProgressIndicator() - MifosOutlineDropdown( - selectedText = state.selectedLoanPurpose, - items = state.loanPurposeOptions, - onItemSelected = { id, product -> - onAction(LoanApplicationAction.PurposeOfLoanChange(product)) - }, - label = stringResource(Res.string.feature_apply_loan_label_purpose), + ScreenUiState.Network -> { + MifosErrorComponent( + isNetworkConnected = state.networkStatus, + isRetryEnabled = true, + onRetry = { onAction(LoanApplicationAction.Retry) }, ) + } - MifosOutlinedTextField( - value = state.disbursementDate, - onValueChange = { }, - label = stringResource(Res.string.feature_apply_loan_label_disbursement_date), - config = MifosTextFieldConfig( - isError = state.disbursementDateError != null, - errorText = state.disbursementDateError?.let { stringResource(it) }, - showClearIcon = false, - readOnly = true, - trailingIcon = { - Icon( - modifier = Modifier.clickable { - onAction(LoanApplicationAction.ToggleDatePicker) - }, - imageVector = MifosIcons.Calendar, - contentDescription = "Open Date Picker", - ) + ScreenUiState.Success -> { + Column( + modifier = Modifier + .padding(DesignToken.padding.large) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.large), + ) { + MifosOutlinedTextField( + value = state.applicantName, + onValueChange = { onAction(LoanApplicationAction.ApplicantNameChange(it)) }, + label = stringResource(Res.string.feature_apply_loan_label_applicant_name), + shape = DesignToken.shapes.medium, + textStyle = MifosTypography.bodyLarge, + config = MifosTextFieldConfig( + isError = state.applicantNameError != null, + errorText = state.applicantNameError?.let { stringResource(it) }, + ), + ) + + MifosOutlineDropdown( + selectedText = state.loanProductName, + items = emptyMap(), + enabled = false, + onItemSelected = { _, _ -> }, + label = stringResource(Res.string.feature_apply_loan_label_loan_product), + ) + + MifosOutlineDropdown( + selectedText = state.selectedLoanPurpose, + items = state.loanPurposeOptions, + onItemSelected = { id, product -> + onAction(LoanApplicationAction.PurposeOfLoanChange(product)) }, - ), - shape = DesignToken.shapes.medium, - ) + label = stringResource(Res.string.feature_apply_loan_label_purpose), + ) - MifosOutlinedTextField( - value = state.principalAmount, - onValueChange = { onAction(LoanApplicationAction.PrincipalAmountChange(it)) }, - label = stringResource(Res.string.feature_apply_loan_label_principal_amount), - config = MifosTextFieldConfig( - isError = state.principalAmountError != null, - errorText = state.principalAmountError?.let { stringResource(it) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, + MifosOutlinedTextField( + value = state.disbursementDate, + onValueChange = { }, + label = stringResource(Res.string.feature_apply_loan_label_disbursement_date), + config = MifosTextFieldConfig( + isError = state.disbursementDateError != null, + errorText = state.disbursementDateError?.let { stringResource(it) }, + showClearIcon = false, + readOnly = true, + trailingIcon = { + Icon( + modifier = Modifier.clickable { + onAction(LoanApplicationAction.ToggleDatePicker) + }, + imageVector = MifosIcons.Calendar, + contentDescription = "Open Date Picker", + ) + }, ), - ), - shape = DesignToken.shapes.medium, - ) + shape = DesignToken.shapes.medium, + ) - MifosButton( - modifier = Modifier.fillMaxWidth().height(DesignToken.sizes.inputHeight), - enabled = state.isFormValid, - onClick = { - onAction(LoanApplicationAction.NavigateToConfirmDetails) - }, - shape = DesignToken.shapes.medium, - ) { - Text( - text = stringResource(Res.string.feature_apply_loan_button_continue), - style = MifosTypography.titleMedium, + MifosOutlinedTextField( + value = state.principalAmount, + onValueChange = { onAction(LoanApplicationAction.PrincipalAmountChange(it)) }, + label = stringResource(Res.string.feature_apply_loan_label_principal_amount), + config = MifosTextFieldConfig( + isError = state.principalAmountError != null, + errorText = state.principalAmountError?.let { stringResource(it) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + ), + shape = DesignToken.shapes.medium, ) + + MifosButton( + modifier = Modifier.fillMaxWidth().height(DesignToken.sizes.inputHeight), + enabled = state.isFormValid, + onClick = { + onAction(LoanApplicationAction.NavigateToConfirmDetails) + }, + shape = DesignToken.shapes.medium, + ) { + Text( + text = stringResource(Res.string.feature_apply_loan_button_continue), + style = MaterialTheme.typography.labelLarge, + ) + } + + if (state.showDatePicker) { + val today = Clock.System.now().toEpochMilliseconds() + val activationMillis = DateHelper.getDateAsLongFromList( + DateHelper.getDateAsList(state.activationDate), + ) ?: 0L + + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = state.currentDate, + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis in activationMillis..today + } + }, + ) + + DatePickerDialog( + onDismissRequest = { + onAction(LoanApplicationAction.ToggleDatePicker) + }, + confirmButton = { + TextButton( + onClick = { + onAction(LoanApplicationAction.ToggleDatePicker) + datePickerState.selectedDateMillis?.let { + onAction( + LoanApplicationAction.DisbursementDateChange( + DateHelper.getDateMonthYearString(it), + ), + ) + } + }, + ) { + Text(stringResource(Res.string.feature_apply_loan_button_ok)) + } + }, + dismissButton = { + TextButton( + onClick = { + onAction(LoanApplicationAction.ToggleDatePicker) + }, + ) { + Text(stringResource(Res.string.feature_apply_loan_button_cancel)) + } + }, + ) { + DatePicker(state = datePickerState) + } + } } } + + else -> { } } } } diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyViewModel.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyViewModel.kt index b6a92f51f4..b1ca5d1906 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyViewModel.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanApplication/LoanApplyViewModel.kt @@ -16,6 +16,7 @@ import androidx.navigation.toRoute import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -32,6 +33,7 @@ import mifos_mobile.feature.loan_application.generated.resources.feature_apply_l import mifos_mobile.feature.loan_application.generated.resources.feature_apply_loan_error_submit_failed import mifos_mobile.feature.loan_application.generated.resources.feature_apply_loan_error_too_many_attempts import mifos_mobile.feature.loan_application.generated.resources.feature_apply_loan_unsaved_changes_message +import okio.IOException import org.jetbrains.compose.resources.StringResource import org.mifos.mobile.core.common.DataState import org.mifos.mobile.core.common.DateHelper @@ -39,10 +41,12 @@ import org.mifos.mobile.core.data.repository.HomeRepository import org.mifos.mobile.core.data.repository.LoanRepository import org.mifos.mobile.core.data.util.NetworkMonitor import org.mifos.mobile.core.datastore.UserPreferencesRepository +import org.mifos.mobile.core.model.entity.client.Client import org.mifos.mobile.core.model.entity.templates.loans.Currency import org.mifos.mobile.core.model.entity.templates.loans.LoanTemplate import org.mifos.mobile.core.ui.utils.AmountValidationResult import org.mifos.mobile.core.ui.utils.BaseViewModel +import org.mifos.mobile.core.ui.utils.ScreenUiState import org.mifos.mobile.core.ui.utils.ValidationHelper import org.mifos.mobile.core.model.entity.Currency as ModelCurrency @@ -81,7 +85,6 @@ internal class LoanApplyViewModel( initialState = run { val route = savedStateHandle.toRoute() LoanApplicationState( - loanApplicationDialogState = LoanApplicationDialogState.Loading, clientId = requireNotNull(userPreferencesRepository.clientId.value), applicantName = "", principalAmount = "", @@ -106,22 +109,7 @@ internal class LoanApplyViewModel( networkMonitor.isOnline .distinctUntilChanged() .collect { isOnline -> - mutableStateFlow.update { - it.copy( - networkStatus = isOnline, - loanApplicationDialogState = if (!isOnline) { - LoanApplicationDialogState.Network - } else { - null - }, - ) - } - - if (isOnline) { - fetchClient() - fetchLoanTemplate() - fetchLoanPurpose() - } + sendAction(LoanApplicationAction.ReceiveNetworkStatus(isOnline)) } } } @@ -154,6 +142,8 @@ internal class LoanApplyViewModel( onDisbursementDateChange(action.disbursementDate) } + is LoanApplicationAction.ReceiveNetworkStatus -> handleNetworkStatus(action.isOnline) + is LoanApplicationAction.NavigateToConfirmDetails -> validateAndSubmit() is LoanApplicationAction.OnNavigateBack -> navigateBack() @@ -166,137 +156,127 @@ internal class LoanApplyViewModel( is LoanApplicationAction.DismissDialog -> dismissDialog() - is LoanApplicationAction.GetLoanPurpose -> fetchLoanPurpose() - - is LoanApplicationAction.Internal.ReceiveLoanTemplate -> handleLoanTemplate(action.template) - - is LoanApplicationAction.Internal.ReceiveLoanPurposeOptions -> - handleLoanPurpose(action.template) + is LoanApplicationAction.Internal.ReceiveClientAndTemplateResult -> + handleClientAndTemplateResult( + client = action.client, + template = action.template, + purpose = action.purpose, + ) LoanApplicationAction.Retry -> retry() } } /** - * Retries the data fetching process. If the network is unavailable, it shows - * a network error dialog. Otherwise, it triggers the `fetchLoanTemplate` `fetchClient`, - * `fetchLonPurpose` function. - */ - private fun retry() { - viewModelScope.launch { - if (!state.networkStatus) { - updateState { it.copy(loanApplicationDialogState = LoanApplicationDialogState.Network) } - } else { - fetchClient() - fetchLoanTemplate() - fetchLoanPurpose() - } - } - } - - /** - * A helper function to update the mutable state flow. + * Handles changes in network connectivity. * - * @param update A lambda function that takes the current state and returns a new state. + * It updates the `networkStatus` state. If the network is offline, it sets the + * `uiState` to [ScreenUiState.Network]. If the network is online, it + * automatically triggers a data fetch to refresh the content. + * + * @param isOnline A boolean indicating the current network status. */ - private fun updateState(update: (LoanApplicationState) -> LoanApplicationState) { - mutableStateFlow.update(update) - } + private fun handleNetworkStatus(isOnline: Boolean) { + updateState { it.copy(networkStatus = isOnline) } - /** - * Fetches the current client's details, specifically the activation date, - * from the repository. - */ - private fun fetchClient() { viewModelScope.launch { - homeRepositoryImpl.currentClient(state.clientId) - .catch { - showErrorDialog(Res.string.feature_apply_loan_error_server) - viewModelScope.launch { - delay(1500) - sendEvent(LoanApplicationEvent.NavigateBack) - } - } - .collect { response -> - response.data?.activationDate?.let { activationDateList -> - updateState { - it.copy( - activationDate = DateHelper.getDateAsString(activationDateList), - ) - } + if (!isOnline) { + updateState { current -> + if (current.uiState is ScreenUiState.Loading || + current.uiState is ScreenUiState.Error || + current.uiState is ScreenUiState.Empty || + current.uiState is ScreenUiState.Network + ) { + current.copy(uiState = ScreenUiState.Network) + } else { + current } } + } else { + getClientDataAndTemplate() + } } } /** - * Fetches the loan template data from the repository. The template contains - * loan product options and currency information. + * Fetches client data, a generic loan template, and a product-specific loan purpose template + * from the repositories. + * The results are combined and handled in a single flow to manage loading and error states. */ - private fun fetchLoanTemplate() { + private fun getClientDataAndTemplate() { + showLoading() viewModelScope.launch { - loanAccountRepositoryImp.template(state.clientId) - .collect { result -> - sendAction(LoanApplicationAction.Internal.ReceiveLoanTemplate(result)) - } - } - } + combine( + homeRepositoryImpl.currentClient(state.clientId), + loanAccountRepositoryImp.template(state.clientId), + loanAccountRepositoryImp.getLoanTemplateByProduct(state.clientId, state.loanProductId), + ) { client, template, purpose -> + Triple(client, template, purpose) + } + .catch { throwable -> - /** - * Fetches loan purpose options based on the currently selected loan product. - * Shows a loading overlay while the data is being fetched. - */ - private fun fetchLoanPurpose() { - showOverlayLoading() - viewModelScope.launch { - loanAccountRepositoryImp.getLoanTemplateByProduct( - state.clientId, - state.loanProductId, - ) - .collect { result -> - sendAction(LoanApplicationAction.Internal.ReceiveLoanPurposeOptions(result)) + updateState { + it.copy( + uiState = if (throwable.cause is IOException) { + ScreenUiState.Network + } else { + ScreenUiState.Error(Res.string.feature_apply_loan_error_server) + }, + ) + } + } + .collect { (client, template, purpose) -> + sendAction( + LoanApplicationAction.Internal.ReceiveClientAndTemplateResult( + client, + template, + purpose, + ), + ) } } } /** - * Sets the dialog state to a full-screen loading spinner. - */ - private fun showLoading() { - updateState { it.copy(loanApplicationDialogState = LoanApplicationDialogState.Loading) } - } + * Handles the result of the combined data fetching operation for client and loan templates. + * It updates the UI state based on whether the data fetching was successful, loading, or failed. + * + * @param client The [DataState] of the client data. + * @param template The [DataState] of the generic loan template. + * @param purpose The [DataState] of the product-specific loan purpose template. + */ + private fun handleClientAndTemplateResult( + client: DataState, + template: DataState, + purpose: DataState, + ) { + when { + listOf(client, template, purpose).any { it is DataState.Loading } -> { + showLoading() + } - /** - * Sets the dialog state to an overlay loading spinner. - */ - private fun showOverlayLoading() { - updateState { it.copy(loanApplicationDialogState = LoanApplicationDialogState.OverlayLoading) } - } + client is DataState.Success && template is DataState.Success && purpose is DataState.Success -> { + val mappedLoanPurposeOptions: Map = purpose.data?.loanPurposeOptions + ?.mapNotNull { option -> + val id = option.id?.toLong() + val name = option.name + if (id != null && name != null) id to name else null + } + ?.takeIf { it.isNotEmpty() } + ?.toMap() + ?: fallbackLoanPurposeMap - /** - * Displays an error dialog with a given message. - * - * @param error The [StringResource] for the error message to display. - */ - private fun showErrorDialog(error: StringResource) { - updateState { it.copy(loanApplicationDialogState = LoanApplicationDialogState.Error(error)) } - } + client.data?.activationDate?.let { activationDate -> + updateState { + it.copy( + activationDate = DateHelper.getDateAsString(activationDate), + ) + } + } - /** - * Handles the result of the `fetchLoanTemplate` network call. - * Updates the state with product options and currency on success, - * or displays an error and navigates back on failure. - * - * @param template The [DataState] containing the loan template data. - */ - private fun handleLoanTemplate(template: DataState) { - when (template) { - is DataState.Loading -> showLoading() - is DataState.Success -> { - val loanTemplate = template.data updateState { it.copy( - currency = loanTemplate?.currency ?: Currency( + currency = template.data?.currency ?: Currency( code = "USD", name = "US Dollar", decimalPlaces = 2.0, @@ -305,52 +285,54 @@ internal class LoanApplyViewModel( nameCode = "currency.USD", displayLabel = "US Dollar ($)", ), - loanApplicationDialogState = null, + loanPurposeOptions = mappedLoanPurposeOptions, + uiState = ScreenUiState.Success, ) } } - is DataState.Error -> { - showErrorDialog(Res.string.feature_apply_loan_error_server) + else -> updateState { it.copy(uiState = ScreenUiState.Error(Res.string.feature_apply_loan_error_server)) } + } + } + + /** + * Retries the data fetching process. If the network is unavailable, it shows + * a network error dialog. Otherwise, it triggers the `fetchLoanTemplate` `fetchClient`, + * `fetchLonPurpose` function. + */ + private fun retry() { + viewModelScope.launch { + if (!state.networkStatus) { + updateState { it.copy(uiState = ScreenUiState.Network) } + } else { + getClientDataAndTemplate() } } } /** - * Handles the result of the `fetchLoanPurpose` network call. - * On success, it maps the loan purpose options and updates the state. - * On failure, it shows an error dialog and navigates back. + * A helper function to update the mutable state flow. * - * @param template The [DataState] containing the loan template data, - * including loan purpose options. - */ - private fun handleLoanPurpose(template: DataState) { - when (template) { - is DataState.Loading -> showOverlayLoading() - is DataState.Success -> { - val mappedLoanPurposeOptions: Map = template.data?.loanPurposeOptions - ?.mapNotNull { option -> - val id = option.id?.toLong() - val name = option.name - if (id != null && name != null) id to name else null - } - ?.takeIf { it.isNotEmpty() } - ?.toMap() - ?: fallbackLoanPurposeMap + * @param update A lambda function that takes the current state and returns a new state. + */ + private fun updateState(update: (LoanApplicationState) -> LoanApplicationState) { + mutableStateFlow.update(update) + } - updateState { - it.copy( - loanProductTemplate = template.data, - loanPurposeOptions = mappedLoanPurposeOptions, - loanApplicationDialogState = null, - ) - } - } + /** + * Sets the dialog state to a full-screen loading spinner. + */ + private fun showLoading() { + updateState { it.copy(uiState = ScreenUiState.Loading) } + } - is DataState.Error -> { - showErrorDialog(Res.string.feature_apply_loan_error_server) - } - } + /** + * Displays an error dialog with a given message. + * + * @param error The [StringResource] for the error message to display. + */ + private fun showErrorDialog(error: StringResource) { + updateState { it.copy(loanApplicationDialogState = LoanApplicationDialogState.Error(error)) } } /** @@ -556,7 +538,6 @@ internal class LoanApplyViewModel( * and sends an event to navigate to the confirmation screen. */ private fun handleSubmit() { - showOverlayLoading() viewModelScope.launch { try { updateState { @@ -621,12 +602,7 @@ internal class LoanApplyViewModel( private fun toggleDatePicker() { mutableStateFlow.update { it.copy( - loanApplicationDialogState = - if (it.loanApplicationDialogState is LoanApplicationDialogState.ShowDatePicker) { - null - } else { - LoanApplicationDialogState.ShowDatePicker(it.currentDate) - }, + showDatePicker = !state.showDatePicker, ) } } @@ -692,6 +668,9 @@ internal data class LoanApplicationState( val disbursementDateError: StringResource? = null, val hasChanges: Boolean = false, val networkStatus: Boolean = false, + + val uiState: ScreenUiState? = ScreenUiState.Loading, + val showDatePicker: Boolean = false, ) { /** * The current time in milliseconds, used for date pickers. @@ -772,27 +751,12 @@ internal data class LoanApplicationState( * shown on the loan application screen. */ internal sealed interface LoanApplicationDialogState { - /** Represents an overlay loading state. */ - data object OverlayLoading : LoanApplicationDialogState - - /** Represents a full-screen loading state. */ - data object Loading : LoanApplicationDialogState - - /** Represents a network error state. */ - data object Network : LoanApplicationDialogState - /** * Represents a generic error dialog with a message. * @property message The [StringResource] for the error message. */ data class Error(val message: StringResource) : LoanApplicationDialogState - /** - * Represents a date picker dialog. - * @property currentDate The current date in milliseconds to pre-select in the picker. - */ - data class ShowDatePicker(val currentDate: Long) : LoanApplicationDialogState - /** * Represents a dialog to confirm navigation with unsaved changes. * @property message The [StringResource] for the confirmation message. @@ -816,6 +780,10 @@ internal sealed interface LoanApplicationEvent { * ViewModel needs to handle. */ internal sealed interface LoanApplicationAction { + + /** Action to observe network status */ + data class ReceiveNetworkStatus(val isOnline: Boolean) : LoanApplicationAction + /** User action to navigate back. */ data object OnNavigateBack : LoanApplicationAction @@ -852,9 +820,6 @@ internal sealed interface LoanApplicationAction { */ data class PrincipalAmountChange(val principalAmount: String) : LoanApplicationAction - /** User action to get the loan purpose options for the selected product. */ - data object GetLoanPurpose : LoanApplicationAction - /** User action to toggle the visibility of the date picker. */ data object ToggleDatePicker : LoanApplicationAction @@ -870,16 +835,21 @@ internal sealed interface LoanApplicationAction { sealed interface Internal : LoanApplicationAction { /** - * An internal action to handle the result of fetching a loan template. - * @property template The [DataState] containing the loan template data. - */ - data class ReceiveLoanTemplate(val template: DataState) : Internal - - /** - * An internal action to handle the result of fetching loan purpose options. - * @property template The [DataState] containing the loan template data. + * An internal action to handle the combined results of fetching client, generic loan template, + * and product-specific loan purpose data. + * + * The ViewModel uses this action to process the asynchronous results from the repositories + * and update the UI state based on the success, loading, or error state of each data fetch. + * + * @property client The [DataState] of the client data. + * @property template The [DataState] of the generic loan template. + * @property purpose The [DataState] of the product-specific loan purpose template. */ - data class ReceiveLoanPurposeOptions(val template: DataState) : Internal + data class ReceiveClientAndTemplateResult( + val client: DataState, + val template: DataState, + val purpose: DataState, + ) : LoanApplicationAction } } diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsScren.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsScren.kt index 5c04c386b1..e98f056eb8 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsScren.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsScren.kt @@ -63,10 +63,10 @@ import org.mifos.mobile.core.ui.component.MifosErrorComponent import org.mifos.mobile.core.ui.component.MifosPoweredCard import org.mifos.mobile.core.ui.component.MifosProgressIndicator import org.mifos.mobile.core.ui.utils.EventsEffect +import org.mifos.mobile.core.ui.utils.ScreenUiState import org.mifos.mobile.feature.loan.application.component.ApplyLoanBottomBar import org.mifos.mobile.feature.loan.application.component.LoanCard import org.mifos.mobile.feature.loan.application.component.TermsAndConditionItem -import org.mifos.mobile.feature.loan.application.loanType.SelectLoanTypeState import mifos_mobile.core.ui.generated.resources.Res as UiRes @Composable @@ -109,8 +109,6 @@ internal fun LoanProductDetailsDialog( onAction: (LoanProductDetailsAction) -> Unit, ) { when (state.dialogState) { - LoanProductDetailsState.DialogState.Loading -> MifosProgressIndicator() - is LoanProductDetailsState.DialogState.Error -> { MifosBasicDialog( visibilityState = BasicDialogState.Shown( @@ -120,16 +118,6 @@ internal fun LoanProductDetailsDialog( ) } - SelectLoanTypeState.DialogState.Loading -> MifosProgressIndicator() - - is LoanProductDetailsState.DialogState.Network -> { - MifosErrorComponent( - isNetworkConnected = state.networkStatus, - isRetryEnabled = true, - onRetry = { onAction(LoanProductDetailsAction.Retry) }, - ) - } - null -> {} } } @@ -146,18 +134,22 @@ internal fun LoanProductDetailsScreenContent( onNavigateBack = { onAction(LoanProductDetailsAction.NavigateBack) }, bottomBar = { Column { - if (state.dialogState == null) { - ApplyLoanBottomBar( - modifier = Modifier, - checked = state.checked, - isEnabled = state.isEnabled, - onCheckedChange = { - onAction(LoanProductDetailsAction.OnChecked(it)) - }, - onApplyClick = { - onAction(LoanProductDetailsAction.ApplyLoan) - }, - ) + when (state.uiState) { + ScreenUiState.Success -> { + ApplyLoanBottomBar( + modifier = Modifier, + checked = state.checked, + isEnabled = state.isEnabled, + onCheckedChange = { + onAction(LoanProductDetailsAction.OnChecked(it)) + }, + onApplyClick = { + onAction(LoanProductDetailsAction.ApplyLoan) + }, + ) + } + + else -> { } } Surface { @@ -170,90 +162,111 @@ internal fun LoanProductDetailsScreenContent( } }, ) { - if (state.dialogState == null) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(DesignToken.padding.large) - .padding(top = DesignToken.padding.large), - verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.largeIncreased), - ) { - item { - LoanCard( - cardImage = UiRes.drawable.ic_icon_dashboard, - title = stringResource(Res.string.feature_loan_get_loan, state.productName), - amount = stringResource(Res.string.feature_loan_up_to, state.principalText), - interestRate = stringResource( - Res.string.feature_loan_interest_rate_in_numbers, - state.minInterest, - state.maxInterest, - ), - ) - } + when (state.uiState) { + ScreenUiState.Loading -> MifosProgressIndicator() - item { - Text( - text = stringResource(Res.string.feature_loan_terms_and_conditions), - style = MifosTypography.titleMediumEmphasized, - ) - Spacer(modifier = Modifier.height(DesignToken.spacing.largeIncreased)) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.extraSmall), - ) { - TermsAndConditionItem( - title = Res.string.feature_loan_sanction_and_disbursement, - description = Res.string - .feature_personal_loan_sanction_and_disbursement_details, - ) + is ScreenUiState.Error -> { + MifosErrorComponent( + isRetryEnabled = true, + message = stringResource(state.uiState.message), + onRetry = { onAction(LoanProductDetailsAction.Retry) }, + ) + } - TermsAndConditionItem( - title = Res.string.feature_loan_interest_rate, - description = Res.string.feature_personal_loan_interest_rate_description, - ) + ScreenUiState.Network -> { + MifosErrorComponent( + isNetworkConnected = state.networkStatus, + isRetryEnabled = true, + onRetry = { onAction(LoanProductDetailsAction.Retry) }, + ) + } - TermsAndConditionItem( - title = Res.string.feature_loan_repayment, - description = Res.string.feature_personal_loan_repayment_details, + ScreenUiState.Success -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(DesignToken.padding.large) + .padding(top = DesignToken.padding.large), + verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.largeIncreased), + ) { + item { + LoanCard( + cardImage = UiRes.drawable.ic_icon_dashboard, + title = stringResource(Res.string.feature_loan_get_loan, state.productName), + amount = stringResource(Res.string.feature_loan_up_to, state.principalText), + interestRate = stringResource( + Res.string.feature_loan_interest_rate_in_numbers, + state.minInterest, + state.maxInterest, + ), ) + } - TermsAndConditionItem( - title = Res.string.feature_loan_security_and_collateral, - description = Res.string.feature_personal_loan_security_and_collateral_details, + item { + Text( + text = stringResource(Res.string.feature_loan_terms_and_conditions), + style = MifosTypography.titleMediumEmphasized, ) + Spacer(modifier = Modifier.height(DesignToken.spacing.largeIncreased)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.extraSmall), + ) { + TermsAndConditionItem( + title = Res.string.feature_loan_sanction_and_disbursement, + description = Res.string + .feature_personal_loan_sanction_and_disbursement_details, + ) - TermsAndConditionItem( - title = Res.string.feature_loan_insurance, - description = Res.string.feature_personal_loan_insurance_details, - ) + TermsAndConditionItem( + title = Res.string.feature_loan_interest_rate, + description = Res.string.feature_personal_loan_interest_rate_description, + ) - TermsAndConditionItem( - title = Res.string.feature_loan_default, - description = Res.string.feature_personal_loan_default_details, - ) + TermsAndConditionItem( + title = Res.string.feature_loan_repayment, + description = Res.string.feature_personal_loan_repayment_details, + ) - TermsAndConditionItem( - title = Res.string.feature_loan_documentation, - description = Res.string.feature_personal_loan_documentation_details, - ) + TermsAndConditionItem( + title = Res.string.feature_loan_security_and_collateral, + description = Res.string.feature_personal_loan_security_and_collateral_details, + ) - TermsAndConditionItem( - title = Res.string.feature_loan_continue_legal_compliance, - description = Res.string.feature_personal_loan_continue_legal_compliance_details, - ) + TermsAndConditionItem( + title = Res.string.feature_loan_insurance, + description = Res.string.feature_personal_loan_insurance_details, + ) - TermsAndConditionItem( - title = Res.string.feature_loan_amendments_and_termination, - description = Res.string.feature_personal_loan_amendments_and_termination_details, - ) + TermsAndConditionItem( + title = Res.string.feature_loan_default, + description = Res.string.feature_personal_loan_default_details, + ) - TermsAndConditionItem( - title = Res.string.feature_loan_jurisdiction, - description = Res.string.feature_personal_loan_jurisdiction_details, - ) + TermsAndConditionItem( + title = Res.string.feature_loan_documentation, + description = Res.string.feature_personal_loan_documentation_details, + ) + + TermsAndConditionItem( + title = Res.string.feature_loan_continue_legal_compliance, + description = Res.string.feature_personal_loan_continue_legal_compliance_details, + ) + + TermsAndConditionItem( + title = Res.string.feature_loan_amendments_and_termination, + description = Res.string.feature_personal_loan_amendments_and_termination_details, + ) + + TermsAndConditionItem( + title = Res.string.feature_loan_jurisdiction, + description = Res.string.feature_personal_loan_jurisdiction_details, + ) + } } } } + else -> { } } } } diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsViewModel.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsViewModel.kt index 037ea53b57..7532112d8d 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsViewModel.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanProductDescription/LoanProductDetailsViewModel.kt @@ -24,6 +24,7 @@ import org.mifos.mobile.core.data.util.NetworkMonitor import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.model.entity.templates.loans.LoanTemplate import org.mifos.mobile.core.ui.utils.BaseViewModel +import org.mifos.mobile.core.ui.utils.ScreenUiState /** * `ViewModel` for the Loan Product Details screen. @@ -51,7 +52,6 @@ internal class LoanProductDetailsViewModel( clientId = requireNotNull(userPreferencesRepositoryImpl.clientId.value), productId = route.productId, productName = route.productName, - dialogState = LoanProductDetailsState.DialogState.Loading, ) }, ) { @@ -69,20 +69,7 @@ internal class LoanProductDetailsViewModel( networkMonitor.isOnline .distinctUntilChanged() .collect { isOnline -> - mutableStateFlow.update { - it.copy( - networkStatus = isOnline, - dialogState = if (!isOnline) { - LoanProductDetailsState.DialogState.Network - } else { - null - }, - ) - } - - if (isOnline) { - fetchLoanTemplateByProduct() - } + sendAction(LoanProductDetailsAction.ReceiveNetworkStatus(isOnline)) } } } @@ -107,6 +94,8 @@ internal class LoanProductDetailsViewModel( } } + is LoanProductDetailsAction.ReceiveNetworkStatus -> handleNetworkStatus(action.isOnline) + LoanProductDetailsAction.NavigateBack -> { sendEvent(LoanProductDetailsEvent.NavigateBack) } @@ -117,6 +106,37 @@ internal class LoanProductDetailsViewModel( } } + /** + * Handles changes in network connectivity. + * + * It updates the `networkStatus` state. If the network is offline, it sets the + * `uiState` to [ScreenUiState.Network]. If the network is online, it + * automatically triggers a data fetch to refresh the content. + * + * @param isOnline A boolean indicating the current network status. + */ + private fun handleNetworkStatus(isOnline: Boolean) { + updateState { it.copy(networkStatus = isOnline) } + + viewModelScope.launch { + if (!isOnline) { + updateState { current -> + if (current.uiState is ScreenUiState.Loading || + current.uiState is ScreenUiState.Error || + current.uiState is ScreenUiState.Empty || + current.uiState is ScreenUiState.Network + ) { + current.copy(uiState = ScreenUiState.Network) + } else { + current + } + } + } else { + fetchLoanTemplateByProduct() + } + } + } + /** * Retries the data fetching process. If the network is unavailable, it shows * a network error dialog. Otherwise, it triggers the `fetchLoanTemplate` function. @@ -124,7 +144,7 @@ internal class LoanProductDetailsViewModel( private fun retry() { viewModelScope.launch { if (!state.networkStatus) { - updateState { it.copy(dialogState = LoanProductDetailsState.DialogState.Network) } + updateState { it.copy(uiState = ScreenUiState.Network) } } else { fetchLoanTemplateByProduct() } @@ -144,7 +164,7 @@ internal class LoanProductDetailsViewModel( * Sets the dialog state to a full-screen loading spinner. */ private fun showLoading() { - updateState { it.copy(dialogState = LoanProductDetailsState.DialogState.Loading) } + updateState { it.copy(uiState = ScreenUiState.Loading) } } /** @@ -152,6 +172,7 @@ internal class LoanProductDetailsViewModel( * * @param error The [StringResource] for the error message to display. */ + @Suppress("UnusedPrivateMember") private fun showErrorDialog(error: StringResource) { updateState { it.copy(dialogState = LoanProductDetailsState.DialogState.Error(error)) } } @@ -160,6 +181,7 @@ internal class LoanProductDetailsViewModel( * Fetches the loan template data for the specific loan product from the repository. */ private fun fetchLoanTemplateByProduct() { + showLoading() viewModelScope.launch { loanAccountRepositoryImpl.getLoanTemplateByProduct(state.clientId, state.productId) .collect { result -> @@ -193,13 +215,17 @@ internal class LoanProductDetailsViewModel( principalText = principalText, minInterest = minInterest.toString(), maxInterest = maxInterest.toString(), - dialogState = null, + uiState = ScreenUiState.Success, ) } } is DataState.Error -> { - showErrorDialog(Res.string.feature_apply_loan_error_server) + updateState { + it.copy( + uiState = ScreenUiState.Error(Res.string.feature_apply_loan_error_server), + ) + } } } } @@ -239,10 +265,12 @@ internal data class LoanProductDetailsState( val productId: Int, val productName: String, val networkStatus: Boolean = true, - val dialogState: DialogState?, + val dialogState: DialogState? = null, val principalText: String = "", val minInterest: String = "", val maxInterest: String = "", + + val uiState: ScreenUiState? = ScreenUiState.Loading, ) { /** * A boolean indicating if the "Apply Loan" button should be enabled. @@ -255,17 +283,11 @@ internal data class LoanProductDetailsState( * shown on the Loan Product Details screen. */ sealed interface DialogState { - /** Represents a full-screen loading state. */ - data object Loading : DialogState - /** * Represents a generic error dialog with a message. * @property error The [StringResource] for the error message. */ data class Error(val error: StringResource) : DialogState - - /** Represents a network connectivity error state. */ - data object Network : DialogState } } @@ -305,6 +327,9 @@ internal sealed interface LoanProductDetailsAction { /** Action to retry fetching data after an error or network issue. */ data object Retry : LoanProductDetailsAction + /** Action to observe network status */ + data class ReceiveNetworkStatus(val isOnline: Boolean) : LoanProductDetailsAction + /** * A sealed interface for internal actions, which are not triggered directly by the UI. */ diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeSceen.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeSceen.kt index ff16f6a79e..9c063974b0 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeSceen.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeSceen.kt @@ -42,6 +42,7 @@ import org.mifos.mobile.core.ui.component.MifosErrorComponent import org.mifos.mobile.core.ui.component.MifosPoweredCard import org.mifos.mobile.core.ui.component.MifosProgressIndicator import org.mifos.mobile.core.ui.utils.EventsEffect +import org.mifos.mobile.core.ui.utils.ScreenUiState @Composable internal fun SelectLoanTypeScreen( @@ -91,16 +92,6 @@ internal fun SelectLoanTypeDialog( ) } - SelectLoanTypeState.DialogState.Loading -> MifosProgressIndicator() - - is SelectLoanTypeState.DialogState.Network -> { - MifosErrorComponent( - isNetworkConnected = state.networkStatus, - isRetryEnabled = true, - onRetry = { onAction(SelectLoanTypeAction.Retry) }, - ) - } - null -> {} } } @@ -127,51 +118,75 @@ internal fun SelectLoanTypeScreenContent( } }, ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(DesignToken.padding.large) - .padding(top = DesignToken.padding.large), - ) { - if (state.dialogState == null) { - Column( - verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.medium), + when (state.uiState) { + ScreenUiState.Empty -> { + MifosErrorComponent( + isRetryEnabled = true, + message = stringResource(Res.string.feature_select_loan_type_empty), + onRetry = { onAction(SelectLoanTypeAction.Retry) }, + ) + } + + is ScreenUiState.Error -> { + MifosErrorComponent( + isRetryEnabled = true, + message = stringResource(state.uiState.message), + onRetry = { onAction(SelectLoanTypeAction.Retry) }, + ) + } + + ScreenUiState.Loading -> MifosProgressIndicator() + + ScreenUiState.Network -> { + MifosErrorComponent( + isNetworkConnected = state.networkStatus, + isRetryEnabled = true, + onRetry = { onAction(SelectLoanTypeAction.Retry) }, + ) + } + + ScreenUiState.Success -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(DesignToken.padding.large) + .padding(top = DesignToken.padding.large), ) { - val productOptions = state.productOptions ?: emptyList() - if (productOptions.isNotEmpty()) { - Text( - text = stringResource(Res.string.feature_select_loan_type_choose_loan), - style = MifosTypography.labelLargeEmphasized, - ) - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(DesignToken.spacing.medium), - content = { - items(productOptions) { loanType -> - MifosExploreCard( - icon = MifosIcons.Money, - text = loanType.name ?: "", - onClick = { - onAction( - SelectLoanTypeAction.NavigateTo( - loanType.id ?: -1, - loanType.name ?: "", - ), - ) - }, - ) - } - }, - ) - } else if (state.isEmpty) { - MifosErrorComponent( - isEmptyData = true, - isRetryEnabled = false, - message = stringResource(Res.string.feature_select_loan_type_empty), - ) + Column( + verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.medium), + ) { + val productOptions = state.productOptions ?: emptyList() + if (productOptions.isNotEmpty()) { + Text( + text = stringResource(Res.string.feature_select_loan_type_choose_loan), + style = MifosTypography.labelLargeEmphasized, + ) + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(DesignToken.spacing.medium), + content = { + items(productOptions) { loanType -> + MifosExploreCard( + icon = MifosIcons.Money, + text = loanType.name ?: "", + onClick = { + onAction( + SelectLoanTypeAction.NavigateTo( + loanType.id ?: -1, + loanType.name ?: "", + ), + ) + }, + ) + } + }, + ) + } } } } + + else -> { } } } } diff --git a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeViewModel.kt b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeViewModel.kt index 4db83764f3..5e238a4421 100644 --- a/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeViewModel.kt +++ b/feature/loan-application/src/commonMain/kotlin/org/mifos/mobile/feature/loan/application/loanType/SelectLoanTypeViewModel.kt @@ -24,6 +24,7 @@ import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.model.entity.templates.loans.LoanTemplate import org.mifos.mobile.core.model.entity.templates.loans.ProductOptions import org.mifos.mobile.core.ui.utils.BaseViewModel +import org.mifos.mobile.core.ui.utils.ScreenUiState /** * `ViewModel` for the Select Loan Type screen. @@ -47,8 +48,6 @@ internal class SelectLoanTypeViewModel( SelectLoanTypeState( clientId = requireNotNull(userPreferencesRepositoryImpl.clientId.value), productOptions = null, - dialogState = SelectLoanTypeState.DialogState.Loading, - isEmpty = false, ) }, ) { @@ -66,20 +65,7 @@ internal class SelectLoanTypeViewModel( networkMonitor.isOnline .distinctUntilChanged() .collect { isOnline -> - updateState { - it.copy( - networkStatus = isOnline, - dialogState = if (!isOnline) { - SelectLoanTypeState.DialogState.Network - } else { - null - }, - ) - } - - if (isOnline) { - fetchLoanTemplate() - } + sendAction(SelectLoanTypeAction.ReceiveNetworkStatus(isOnline)) } } } @@ -102,12 +88,45 @@ internal class SelectLoanTypeViewModel( is SelectLoanTypeAction.Internal.ReceiveLoanTemplate -> handleLoanTemplate(action.template) + is SelectLoanTypeAction.ReceiveNetworkStatus -> handleNetworkStatus(action.isOnline) + SelectLoanTypeAction.DismissDialog -> handleDismissDialog() SelectLoanTypeAction.Retry -> retry() } } + /** + * Handles changes in network connectivity. + * + * It updates the `networkStatus` state. If the network is offline, it sets the + * `uiState` to [ScreenUiState.Network]. If the network is online, it + * automatically triggers a data fetch to refresh the content. + * + * @param isOnline A boolean indicating the current network status. + */ + private fun handleNetworkStatus(isOnline: Boolean) { + updateState { it.copy(networkStatus = isOnline) } + + viewModelScope.launch { + if (!isOnline) { + updateState { current -> + if (current.uiState is ScreenUiState.Loading || + current.uiState is ScreenUiState.Error || + current.uiState is ScreenUiState.Empty || + current.uiState is ScreenUiState.Network + ) { + current.copy(uiState = ScreenUiState.Network) + } else { + current + } + } + } else { + fetchLoanTemplate() + } + } + } + /** * Dismisses any currently visible dialog by setting the dialog state to null. */ @@ -124,7 +143,7 @@ internal class SelectLoanTypeViewModel( private fun retry() { viewModelScope.launch { if (!state.networkStatus) { - updateState { it.copy(dialogState = SelectLoanTypeState.DialogState.Network) } + updateState { it.copy(uiState = ScreenUiState.Network) } } else { fetchLoanTemplate() } @@ -144,7 +163,7 @@ internal class SelectLoanTypeViewModel( * Sets the dialog state to a full-screen loading spinner. */ private fun showLoading() { - updateState { it.copy(dialogState = SelectLoanTypeState.DialogState.Loading) } + updateState { it.copy(uiState = ScreenUiState.Loading) } } /** @@ -152,6 +171,7 @@ internal class SelectLoanTypeViewModel( * * @param error The [StringResource] for the error message to display. */ + @Suppress("UnusedPrivateMember") private fun showErrorDialog(error: StringResource) { updateState { it.copy(dialogState = SelectLoanTypeState.DialogState.Error(error)) } } @@ -161,6 +181,7 @@ internal class SelectLoanTypeViewModel( * loan product options and currency information. */ private fun fetchLoanTemplate() { + showLoading() viewModelScope.launch { loanAccountRepositoryImpl.template(state.clientId) .collect { result -> @@ -184,20 +205,25 @@ internal class SelectLoanTypeViewModel( val loanTemplate = template.data if (loanTemplate?.productOptions?.isEmpty() == true) { updateState { - it.copy(isEmpty = true, dialogState = null) + it.copy( + uiState = ScreenUiState.Empty, + ) } return } updateState { it.copy( - isEmpty = false, + uiState = ScreenUiState.Success, productOptions = loanTemplate?.productOptions, - dialogState = null, ) } } is DataState.Error -> { - showErrorDialog(Res.string.feature_apply_loan_error_server) + updateState { + it.copy( + uiState = ScreenUiState.Error(Res.string.feature_apply_loan_error_server), + ) + } } } } @@ -207,35 +233,30 @@ internal class SelectLoanTypeViewModel( * Represents the UI state for the Select Loan Type screen. * * @property clientId The ID of the current client. - * @property isEmpty A boolean indicating if the list of loan products is empty. - * @property productOptions The list of available loan product options, or `null`. + * @property productOptions The list of available loan product options, or `null` if not yet loaded. * @property dialogState The state of any dialog to be shown on the screen. * @property networkStatus A boolean indicating the current network connectivity status. + * @property uiState The generic screen UI state, such as + * [ScreenUiState.Loading], [ScreenUiState.Success], or [ScreenUiState.Error]. */ @Immutable internal data class SelectLoanTypeState( val clientId: Long, - val isEmpty: Boolean = false, val productOptions: List? = null, - val dialogState: DialogState?, + val dialogState: DialogState? = null, val networkStatus: Boolean = true, + val uiState: ScreenUiState? = ScreenUiState.Loading, ) { /** * A sealed interface representing the different types of dialogs that can be * shown on the Select Loan Type screen. */ sealed interface DialogState { - /** Represents a full-screen loading state. */ - data object Loading : DialogState - /** * Represents a generic error dialog with a message. * @property error The [StringResource] for the error message. */ data class Error(val error: StringResource) : DialogState - - /** Represents a network connectivity error state. */ - data object Network : DialogState } } @@ -249,7 +270,9 @@ internal sealed interface SelectLoanTypeEvent { /** * Event to navigate to the loan application screen for a specific loan type. - * @property loanType The selected [ProductOptions] to pass to the next screen. + * + * @property productId The ID of the selected loan product to pass to the next screen. + * @property productName The name of the selected loan product. */ data class NavigateTo(val productId: Int, val productName: String) : SelectLoanTypeEvent } @@ -268,9 +291,17 @@ internal sealed interface SelectLoanTypeAction { /** Action to navigate back from the screen. */ data object NavigateBack : SelectLoanTypeAction + /** + * An action to receive and handle the network connectivity status. + * @property isOnline A boolean indicating whether the network is available. + */ + data class ReceiveNetworkStatus(val isOnline: Boolean) : SelectLoanTypeAction + /** * Action to navigate to the loan application screen for a specific loan type. - * @property loanType The selected [ProductOptions]. + * + * @property productId The ID of the selected loan product. + * @property productName The name of the selected loan product. */ data class NavigateTo(val productId: Int, val productName: String) : SelectLoanTypeAction