mirror of
https://github.com/openMF/mifos-mobile.git
synced 2026-02-06 11:26:51 +00:00
feat(client-charge): add client charges screen accessible from home (#3049)
This commit is contained in:
parent
835646ce98
commit
be1b948e79
@ -22,6 +22,20 @@
|
||||
<string name="amount_paid">Paid:</string>
|
||||
<string name="feature_client_charge_share_charges">Share Charges</string>
|
||||
|
||||
<string name="feature_client_charges_filter_charges">Filter Charges</string>
|
||||
<string name="feature_client_charges_clear_all">Clear All</string>
|
||||
<string name="feature_client_charges_account_type">Account Type:</string>
|
||||
<string name="feature_client_charges_select_account">Select Account:</string>
|
||||
<string name="feature_client_charges_all_accounts">All Accounts</string>
|
||||
<string name="feature_client_charges_account_label">Account</string>
|
||||
<string name="feature_client_charges_charge_status">Charge Status:</string>
|
||||
<string name="feature_client_charges_apply_filters">Apply Filters</string>
|
||||
<string name="feature_client_charges_account_type_savings">Savings</string>
|
||||
<string name="feature_client_charges_account_type_loan">Loan</string>
|
||||
<string name="feature_client_charges_account_type_shares">Shares</string>
|
||||
<string name="feature_client_charges_charge_id">ChargeId : %1$s</string>
|
||||
|
||||
|
||||
|
||||
<string name="charge_details">Charge Details</string>
|
||||
<string name="paid_success_message">You have successfully paid this charge completely</string>
|
||||
|
||||
@ -22,6 +22,20 @@
|
||||
<string name="amount_paid">Paid:</string>
|
||||
<string name="feature_client_charge_share_charges">Share Charges</string>
|
||||
|
||||
<string name="feature_client_charges_filter_charges">Filter Charges</string>
|
||||
<string name="feature_client_charges_clear_all">Clear All</string>
|
||||
<string name="feature_client_charges_account_type">Account Type:</string>
|
||||
<string name="feature_client_charges_select_account">Select Account:</string>
|
||||
<string name="feature_client_charges_all_accounts">All Accounts</string>
|
||||
<string name="feature_client_charges_account_label">Account</string>
|
||||
<string name="feature_client_charges_charge_status">Charge Status:</string>
|
||||
<string name="feature_client_charges_apply_filters">Apply Filters</string>
|
||||
<string name="feature_client_charges_account_type_savings">Savings</string>
|
||||
<string name="feature_client_charges_account_type_loan">Loan</string>
|
||||
<string name="feature_client_charges_account_type_shares">Shares</string>
|
||||
<string name="feature_client_charges_charge_id">ChargeId : %1$s</string>
|
||||
|
||||
|
||||
|
||||
<string name="charge_details">Charge Details</string>
|
||||
<string name="paid_success_message">You have successfully paid this charge completely</string>
|
||||
|
||||
@ -9,30 +9,70 @@
|
||||
*/
|
||||
package org.mifos.mobile.feature.charge.charges
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import mifos_mobile.feature.client_charge.generated.resources.Res
|
||||
import mifos_mobile.feature.client_charge.generated.resources.database_warning
|
||||
import mifos_mobile.feature.client_charge.generated.resources.error_no_charge
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_account_label
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_account_type
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_account_type_loan
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_account_type_savings
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_account_type_shares
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_all_accounts
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_apply_filters
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_charge_status
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_clear_all
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_filter_charges
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_select_account
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.mifos.mobile.core.designsystem.component.BasicDialogState
|
||||
import org.mifos.mobile.core.designsystem.component.MifosBasicDialog
|
||||
import org.mifos.mobile.core.designsystem.component.MifosElevatedScaffold
|
||||
import org.mifos.mobile.core.designsystem.icon.MifosIcons
|
||||
import org.mifos.mobile.core.designsystem.theme.DesignToken
|
||||
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
|
||||
import org.mifos.mobile.core.model.entity.Charge
|
||||
import org.mifos.mobile.core.model.enums.ChargeType
|
||||
@ -42,17 +82,10 @@ 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.charge.components.ChargeFilterUtil
|
||||
import org.mifos.mobile.feature.charge.components.ClientChargeItem
|
||||
import template.core.base.designsystem.theme.KptTheme
|
||||
|
||||
/**
|
||||
* Composable function that displays the Client Charges Screen.
|
||||
*
|
||||
* @param navigateBack A lambda function that is called when the user navigates back.
|
||||
* @param onChargeClick A lambda function that is called when the user clicks on a charge.
|
||||
* @param modifier Modifier to be applied to the layout.
|
||||
* @param viewModel ViewModel that provides the state and actions for the screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClientChargeScreen(
|
||||
navigateBack: () -> Unit,
|
||||
@ -95,23 +128,30 @@ internal fun ClientChargeScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable function that displays the Client Charges Screen.
|
||||
*
|
||||
* @param state State of the screen.
|
||||
* @param modifier Modifier to be applied to the layout.
|
||||
* @param onAction A lambda function that is called when the user performs an action.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ClientChargeScreen(
|
||||
state: ClientChargeState,
|
||||
modifier: Modifier = Modifier,
|
||||
onAction: (ClientChargeAction) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
val title = stringResource(state.topBarTitleResId) +
|
||||
(state.selectedAccountNo?.let { " - $it" } ?: "")
|
||||
|
||||
MifosElevatedScaffold(
|
||||
topBarTitle = stringResource(state.topBarTitleResId),
|
||||
onNavigateBack = { onAction(ClientChargeAction.OnNavigate) },
|
||||
modifier = modifier,
|
||||
topBarTitle = title,
|
||||
onNavigateBack = { onAction(ClientChargeAction.OnNavigate) },
|
||||
actions = {
|
||||
IconButton(onClick = { onAction(ClientChargeAction.ToggleFilter) }) {
|
||||
Icon(
|
||||
imageVector = MifosIcons.Filter,
|
||||
contentDescription = "Filter",
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Surface {
|
||||
MifosPoweredCard(
|
||||
@ -121,56 +161,74 @@ private fun ClientChargeScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
when (state.uiState) {
|
||||
ScreenUiState.Empty -> {
|
||||
EmptyDataView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
image = Res.drawable.database_warning,
|
||||
error = Res.string.error_no_charge,
|
||||
content = {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (state.uiState) {
|
||||
ScreenUiState.Empty -> {
|
||||
EmptyDataView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
image = Res.drawable.database_warning,
|
||||
error = Res.string.error_no_charge,
|
||||
)
|
||||
}
|
||||
|
||||
)
|
||||
is ScreenUiState.Error -> {
|
||||
MifosErrorComponent(
|
||||
isRetryEnabled = true,
|
||||
message = stringResource(state.uiState.message),
|
||||
onRetry = { onAction(ClientChargeAction.Retry) },
|
||||
)
|
||||
}
|
||||
|
||||
ScreenUiState.Loading -> MifosProgressIndicator()
|
||||
|
||||
ScreenUiState.Network -> {
|
||||
MifosErrorComponent(
|
||||
isNetworkConnected = state.networkStatus,
|
||||
isRetryEnabled = true,
|
||||
onRetry = { onAction(ClientChargeAction.Retry) },
|
||||
)
|
||||
}
|
||||
|
||||
ScreenUiState.Success -> {
|
||||
ClientChargeContent(
|
||||
modifier = Modifier.padding(KptTheme.spacing.lg),
|
||||
chargesList = state.charges,
|
||||
onChargeClick = {
|
||||
onAction(ClientChargeAction.OnChargeClick(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
is ScreenUiState.Error -> {
|
||||
MifosErrorComponent(
|
||||
isRetryEnabled = true,
|
||||
message = stringResource(state.uiState.message),
|
||||
onRetry = { onAction(ClientChargeAction.Retry) },
|
||||
)
|
||||
if (state.showFilter) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { onAction(ClientChargeAction.ToggleFilter) },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
ChargeFilterSheetContent(
|
||||
state = state,
|
||||
onApply = { target, filter ->
|
||||
onAction(
|
||||
ClientChargeAction.ApplyFilter(
|
||||
target = target,
|
||||
filter = filter,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClear = {
|
||||
onAction(ClientChargeAction.ClearFilter)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ScreenUiState.Loading -> MifosProgressIndicator()
|
||||
|
||||
ScreenUiState.Network -> {
|
||||
MifosErrorComponent(
|
||||
isNetworkConnected = state.networkStatus,
|
||||
isRetryEnabled = true,
|
||||
onRetry = { onAction(ClientChargeAction.Retry) },
|
||||
)
|
||||
}
|
||||
|
||||
ScreenUiState.Success -> {
|
||||
ClientChargeContent(
|
||||
modifier = Modifier.padding(KptTheme.spacing.md),
|
||||
chargesList = state.charges,
|
||||
onChargeClick = {
|
||||
onAction(ClientChargeAction.OnChargeClick(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable function that displays the content of the Client Charges Screen.
|
||||
*
|
||||
* @param chargesList List of charges to be displayed.
|
||||
* @param onChargeClick A lambda function that is called when the user clicks on a charge.
|
||||
* @param modifier Modifier to be applied to the layout.
|
||||
*/
|
||||
@Composable
|
||||
private fun ClientChargeContent(
|
||||
chargesList: List<Charge>,
|
||||
@ -186,12 +244,6 @@ private fun ClientChargeContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable function that displays the dialogs of the Client Charges Screen.
|
||||
*
|
||||
* @param dialogState Dialog state used for showing loading or error.
|
||||
* @param onDismissRequest A lambda function that is called when the user dismisses the dialog.
|
||||
*/
|
||||
@Composable
|
||||
private fun ClientChargeDialogs(
|
||||
dialogState: ClientChargeState.DialogState?,
|
||||
@ -206,11 +258,357 @@ private fun ClientChargeDialogs(
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChargeFilterSheetContent(
|
||||
state: ClientChargeState,
|
||||
onApply: (ChargeAccountTarget, ChargeFilterUtil) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val savingsLabel = stringResource(Res.string.feature_client_charges_account_type_savings)
|
||||
val loanLabel = stringResource(Res.string.feature_client_charges_account_type_loan)
|
||||
val sharesLabel = stringResource(Res.string.feature_client_charges_account_type_shares)
|
||||
|
||||
var selectedTabLabel by rememberSaveable {
|
||||
mutableStateOf(
|
||||
when {
|
||||
state.selectedLoanAccount != null || state.chargeType == ChargeType.LOAN -> loanLabel
|
||||
state.selectedShareAccount != null || state.chargeType == ChargeType.SHARE -> sharesLabel
|
||||
else -> savingsLabel
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var selectedTarget by remember {
|
||||
mutableStateOf(
|
||||
when {
|
||||
state.selectedSavingsAccount != null ->
|
||||
ChargeAccountTarget.Savings(state.selectedSavingsAccount)
|
||||
|
||||
state.selectedLoanAccount != null ->
|
||||
ChargeAccountTarget.Loan(state.selectedLoanAccount)
|
||||
|
||||
state.selectedShareAccount != null ->
|
||||
ChargeAccountTarget.Share(state.selectedShareAccount)
|
||||
|
||||
else -> ChargeAccountTarget.AllAccounts
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var selectedFilter by remember { mutableStateOf(state.activeFilter) }
|
||||
|
||||
val currentAccountList: List<ChargeAccountTarget> = when (selectedTabLabel) {
|
||||
savingsLabel -> state.savingsAccounts.map { ChargeAccountTarget.Savings(it) }
|
||||
loanLabel -> state.loanAccounts.map { ChargeAccountTarget.Loan(it) }
|
||||
sharesLabel -> state.shareAccounts.map { ChargeAccountTarget.Share(it) }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = KptTheme.spacing.xl,
|
||||
vertical = KptTheme.spacing.lg,
|
||||
),
|
||||
) {
|
||||
FilterHeader(onClear = onClear)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = KptTheme.spacing.sm))
|
||||
|
||||
if (state.canSwitchAccounts) {
|
||||
AccountTypeSection(
|
||||
selectedTabLabel = selectedTabLabel,
|
||||
onTabSelected = { newTab ->
|
||||
selectedTabLabel = newTab
|
||||
selectedTarget = ChargeAccountTarget.AllAccounts
|
||||
},
|
||||
)
|
||||
|
||||
if (currentAccountList.isNotEmpty()) {
|
||||
AccountDropdownSection(
|
||||
accounts = currentAccountList,
|
||||
selectedTarget = selectedTarget,
|
||||
onTargetSelected = { selectedTarget = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChargeStatusSection(
|
||||
selectedFilter = selectedFilter,
|
||||
onFilterSelected = { selectedFilter = it },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(KptTheme.spacing.md))
|
||||
|
||||
FilterApplyButton(
|
||||
onClick = {
|
||||
onApply(selectedTarget, selectedFilter)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(KptTheme.spacing.lg))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterHeader(onClear: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_filter_charges),
|
||||
style = KptTheme.typography.titleLarge,
|
||||
)
|
||||
TextButton(onClick = onClear) {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_clear_all),
|
||||
style = KptTheme.typography.bodyMedium.copy(
|
||||
color = KptTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountTypeSection(
|
||||
selectedTabLabel: String,
|
||||
onTabSelected: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_account_type),
|
||||
style = KptTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
modifier = Modifier.padding(vertical = KptTheme.spacing.sm),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm)) {
|
||||
val types = listOf(
|
||||
stringResource(Res.string.feature_client_charges_account_type_savings),
|
||||
stringResource(Res.string.feature_client_charges_account_type_loan),
|
||||
stringResource(Res.string.feature_client_charges_account_type_shares),
|
||||
)
|
||||
types.forEach { type ->
|
||||
FilterOptionChip(
|
||||
label = type,
|
||||
isSelected = selectedTabLabel == type,
|
||||
onClick = { onTabSelected(type) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(KptTheme.spacing.lg))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountDropdownSection(
|
||||
accounts: List<ChargeAccountTarget>,
|
||||
selectedTarget: ChargeAccountTarget,
|
||||
onTargetSelected: (ChargeAccountTarget) -> Unit,
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_select_account),
|
||||
style = KptTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
modifier = Modifier.padding(bottom = KptTheme.spacing.sm),
|
||||
)
|
||||
|
||||
Box {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { isExpanded = true },
|
||||
shape = KptTheme.shapes.medium,
|
||||
border = BorderStroke(DesignToken.strokes.thin, color = KptTheme.colorScheme.onSecondaryContainer),
|
||||
elevation = CardDefaults.cardElevation(KptTheme.elevation.level0),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(KptTheme.spacing.md),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val (_, accNo) = getAccountDetails(selectedTarget)
|
||||
Text(
|
||||
text = accNo ?: stringResource(
|
||||
Res.string.feature_client_charges_all_accounts,
|
||||
),
|
||||
style = KptTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
Icon(
|
||||
imageVector = MifosIcons.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = { isExpanded = false },
|
||||
modifier = Modifier.fillMaxWidth(0.9f),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_all_accounts),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onTargetSelected(ChargeAccountTarget.AllAccounts)
|
||||
isExpanded = false
|
||||
},
|
||||
)
|
||||
|
||||
accounts.forEach { account ->
|
||||
val (productName, accountNo) = getAccountDetails(account)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = productName
|
||||
?: stringResource(
|
||||
Res.string.feature_client_charges_account_label,
|
||||
),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = accountNo ?: "",
|
||||
style = KptTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onTargetSelected(account)
|
||||
isExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(KptTheme.spacing.sm))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChargeStatusSection(
|
||||
selectedFilter: ChargeFilterUtil,
|
||||
onFilterSelected: (ChargeFilterUtil) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_charge_status),
|
||||
style = KptTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
modifier = Modifier.padding(bottom = KptTheme.spacing.md),
|
||||
)
|
||||
|
||||
val filtersFirstRow = listOf(ChargeFilterUtil.ALL, ChargeFilterUtil.PAID)
|
||||
val filtersSecondRow = listOf(ChargeFilterUtil.PENDING, ChargeFilterUtil.WAIVED)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md),
|
||||
) {
|
||||
filtersFirstRow.forEach { filter ->
|
||||
FilterOptionChip(
|
||||
label = stringResource(filter.label),
|
||||
isSelected = selectedFilter == filter,
|
||||
onClick = { onFilterSelected(filter) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md),
|
||||
) {
|
||||
filtersSecondRow.forEach { filter ->
|
||||
FilterOptionChip(
|
||||
label = stringResource(filter.label),
|
||||
isSelected = selectedFilter == filter,
|
||||
onClick = { onFilterSelected(filter) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterApplyButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = KptTheme.shapes.extraLarge,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = KptTheme.colorScheme.primary),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_client_charges_apply_filters),
|
||||
fontStyle = KptTheme.typography.bodyLarge.fontStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAccountDetails(target: ChargeAccountTarget): Pair<String?, String?> {
|
||||
return when (target) {
|
||||
is ChargeAccountTarget.Savings -> target.account.productName to target.account.accountNo
|
||||
is ChargeAccountTarget.Loan -> target.account.productName to target.account.accountNo
|
||||
is ChargeAccountTarget.Share -> target.account.productName to target.account.accountNo
|
||||
ChargeAccountTarget.AllAccounts -> null to null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FilterOptionChip(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.height(KptTheme.spacing.xl),
|
||||
shape = KptTheme.shapes.extraLarge,
|
||||
color = if (isSelected) KptTheme.colorScheme.primary else KptTheme.colorScheme.surface,
|
||||
border = if (!isSelected) {
|
||||
BorderStroke(
|
||||
DesignToken.strokes.thin,
|
||||
KptTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isSelected) {
|
||||
KptTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
KptTheme.colorScheme.onSurface
|
||||
},
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ClientChargeScreenPreview() {
|
||||
|
||||
@ -19,36 +19,28 @@ import kotlinx.io.IOException
|
||||
import mifos_mobile.feature.client_charge.generated.resources.Res
|
||||
import mifos_mobile.feature.client_charge.generated.resources.charges
|
||||
import mifos_mobile.feature.client_charge.generated.resources.client_charges
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charge_share_charges
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_generic_error_server
|
||||
import mifos_mobile.feature.client_charge.generated.resources.loan_charges
|
||||
import mifos_mobile.feature.client_charge.generated.resources.savings_charges
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.mifos.mobile.core.common.Constants
|
||||
import org.mifos.mobile.core.common.DataState
|
||||
import org.mifos.mobile.core.data.repository.AccountsRepository
|
||||
import org.mifos.mobile.core.data.repository.ClientChargeRepository
|
||||
import org.mifos.mobile.core.data.util.NetworkMonitor
|
||||
import org.mifos.mobile.core.datastore.UserPreferencesRepository
|
||||
import org.mifos.mobile.core.model.entity.Charge
|
||||
import org.mifos.mobile.core.model.entity.Page
|
||||
import org.mifos.mobile.core.model.entity.accounts.loan.LoanAccount
|
||||
import org.mifos.mobile.core.model.entity.accounts.savings.SavingAccount
|
||||
import org.mifos.mobile.core.model.entity.accounts.share.ShareAccount
|
||||
import org.mifos.mobile.core.model.enums.ChargeType
|
||||
import org.mifos.mobile.core.ui.utils.BaseViewModel
|
||||
import org.mifos.mobile.core.ui.utils.ScreenUiState
|
||||
import org.mifos.mobile.feature.charge.components.ChargeFilterUtil
|
||||
|
||||
/**
|
||||
* ViewModel responsible for managing the state of client, loan, and savings charges.
|
||||
*
|
||||
* Handles:
|
||||
* - Fetching charges based on charge type (CLIENT, LOAN, SAVINGS)
|
||||
* - Displaying loading or error states
|
||||
* - Listening to network status updates
|
||||
* - Emitting UI events (toast, navigation)
|
||||
*
|
||||
* @property clientChargeRepositoryImp Repository for retrieving charge data
|
||||
* @property networkMonitor Used to observe current network connectivity
|
||||
* @property userPreferencesRepositoryImpl Provides client-specific information like clientId
|
||||
* @property savedStateHandle Retrieves navigation arguments via `ClientChargesRoute`
|
||||
*/
|
||||
internal class ClientChargeViewModel(
|
||||
private val accountsRepositoryImpl: AccountsRepository,
|
||||
private val clientChargeRepositoryImp: ClientChargeRepository,
|
||||
userPreferencesRepositoryImpl: UserPreferencesRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
@ -56,20 +48,23 @@ internal class ClientChargeViewModel(
|
||||
) : BaseViewModel<ClientChargeState, ClientChargeEvent, ClientChargeAction>(
|
||||
initialState = run {
|
||||
val chargeRoute = savedStateHandle.toRoute<ClientChargesRoute>()
|
||||
val chargeType = ChargeType.valueOf(chargeRoute.chargeType.uppercase())
|
||||
val initialType = ChargeType.valueOf(chargeRoute.chargeType.uppercase())
|
||||
|
||||
val topBarId = when (chargeType) {
|
||||
val topBarId = when (initialType) {
|
||||
ChargeType.CLIENT -> Res.string.client_charges
|
||||
ChargeType.SAVINGS -> Res.string.savings_charges
|
||||
ChargeType.LOAN -> Res.string.loan_charges
|
||||
ChargeType.SHARE -> Res.string.feature_client_charge_share_charges
|
||||
else -> Res.string.charges
|
||||
}
|
||||
|
||||
val canSwitch = initialType == ChargeType.CLIENT
|
||||
|
||||
ClientChargeState(
|
||||
chargeType = ChargeType.valueOf(chargeRoute.chargeType),
|
||||
chargeType = initialType,
|
||||
chargeTypeId = chargeRoute.chargeTypeId,
|
||||
clientId = requireNotNull(userPreferencesRepositoryImpl.clientId.value),
|
||||
topBarTitleResId = topBarId,
|
||||
canSwitchAccounts = canSwitch,
|
||||
isOnline = false,
|
||||
)
|
||||
},
|
||||
@ -79,66 +74,237 @@ internal class ClientChargeViewModel(
|
||||
observeNetworkStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the network connectivity status and updates the UI state accordingly.
|
||||
* If the network is unavailable, it sets the `networkStatus` flag in the state
|
||||
* and shows a network-related dialog.
|
||||
*/
|
||||
private fun observeNetworkStatus() {
|
||||
viewModelScope.launch {
|
||||
networkMonitor.isOnline
|
||||
.distinctUntilChanged()
|
||||
.collect { isOnline ->
|
||||
|
||||
sendAction(ClientChargeAction.ReceiveNetworkResult(isOnline = isOnline))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the UI state by applying a transformation.
|
||||
*/
|
||||
private fun updateState(update: (ClientChargeState) -> ClientChargeState) {
|
||||
mutableStateFlow.update(update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all dispatched actions.
|
||||
*/
|
||||
override fun handleAction(action: ClientChargeAction) {
|
||||
when (action) {
|
||||
is ClientChargeAction.RefreshCharges -> refreshCharges()
|
||||
|
||||
is ClientChargeAction.OnNavigate -> sendEvent(ClientChargeEvent.Navigate)
|
||||
is ClientChargeAction.OnDismissDialog -> mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
is ClientChargeAction.OnChargeClick -> sendEvent(
|
||||
ClientChargeEvent.OnChargeClick(action.charge),
|
||||
)
|
||||
|
||||
is ClientChargeAction.OnDismissDialog -> dismissDialog()
|
||||
|
||||
is ClientChargeAction.OnChargeClick -> sendEvent(ClientChargeEvent.OnChargeClick(action.charge))
|
||||
|
||||
is ClientChargeAction.RefreshCharges -> loadCharges()
|
||||
is ClientChargeAction.Retry -> {
|
||||
viewModelScope.launch {
|
||||
if (!state.networkStatus) {
|
||||
updateState { it.copy(uiState = ScreenUiState.Network) }
|
||||
} else {
|
||||
loadCharges()
|
||||
}
|
||||
}
|
||||
}
|
||||
is ClientChargeAction.ReceiveNetworkResult -> handleNetworkResult(action.isOnline)
|
||||
|
||||
is ClientChargeAction.Retry -> retry()
|
||||
is ClientChargeAction.ToggleFilter,
|
||||
is ClientChargeAction.ClearFilter,
|
||||
is ClientChargeAction.ApplyFilter,
|
||||
-> handleFilterAction(action)
|
||||
|
||||
is ClientChargeAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFilterAction(action: ClientChargeAction) {
|
||||
when (action) {
|
||||
is ClientChargeAction.ToggleFilter -> {
|
||||
updateState { it.copy(showFilter = !it.showFilter) }
|
||||
}
|
||||
is ClientChargeAction.ClearFilter -> performClearFilter()
|
||||
is ClientChargeAction.ApplyFilter -> performApplyFilter(action)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: ClientChargeAction.Internal) {
|
||||
when (action) {
|
||||
is ClientChargeAction.Internal.ReceiveClientChargesResult ->
|
||||
handleClientChargesResult(action.result)
|
||||
|
||||
is ClientChargeAction.Internal.ReceiveLoanOrSavingsChargesResult ->
|
||||
handleLoanOrSavingsChargesResult(action.result)
|
||||
|
||||
is ClientChargeAction.Internal.ReceiveShareChargesResult ->
|
||||
handleShareChargesResult(action.result)
|
||||
is ClientChargeAction.Internal.SavingsAccountsLoaded ->
|
||||
updateSavingsAccounts(action.accounts)
|
||||
is ClientChargeAction.Internal.LoanAccountsLoaded ->
|
||||
updateLoanAccounts(action.accounts)
|
||||
is ClientChargeAction.Internal.ShareAccountsLoaded ->
|
||||
updateShareAccounts(action.accounts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the result of the network status.
|
||||
*
|
||||
* @param isOnline Boolean indicating if the network is online.
|
||||
*/
|
||||
private fun handleNetworkResult(isOnline: Boolean) {
|
||||
updateState {
|
||||
it.copy(networkStatus = isOnline)
|
||||
private fun performClearFilter() {
|
||||
if (state.canSwitchAccounts) {
|
||||
updateState {
|
||||
it.copy(
|
||||
selectedSavingsAccount = null,
|
||||
selectedLoanAccount = null,
|
||||
selectedShareAccount = null,
|
||||
activeFilter = ChargeFilterUtil.ALL,
|
||||
chargeType = ChargeType.CLIENT,
|
||||
chargeTypeId = null,
|
||||
topBarTitleResId = Res.string.client_charges,
|
||||
showFilter = false,
|
||||
)
|
||||
}
|
||||
loadCharges()
|
||||
} else {
|
||||
updateState {
|
||||
it.copy(
|
||||
activeFilter = ChargeFilterUtil.ALL,
|
||||
showFilter = false,
|
||||
)
|
||||
}
|
||||
applyLocalFilter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performApplyFilter(action: ClientChargeAction.ApplyFilter) {
|
||||
val previousId = state.chargeTypeId
|
||||
val previousType = state.chargeType
|
||||
|
||||
val newFilter = action.filter
|
||||
|
||||
val newChargeType = when (action.target) {
|
||||
is ChargeAccountTarget.Savings -> ChargeType.SAVINGS
|
||||
is ChargeAccountTarget.Loan -> ChargeType.LOAN
|
||||
is ChargeAccountTarget.Share -> ChargeType.SHARE
|
||||
ChargeAccountTarget.AllAccounts -> ChargeType.CLIENT
|
||||
}
|
||||
|
||||
val newId = when (val target = action.target) {
|
||||
is ChargeAccountTarget.Savings -> target.account.id
|
||||
is ChargeAccountTarget.Loan -> target.account.id
|
||||
is ChargeAccountTarget.Share -> target.account.id
|
||||
ChargeAccountTarget.AllAccounts -> null
|
||||
}
|
||||
|
||||
val newTitle = when (newChargeType) {
|
||||
ChargeType.SAVINGS -> Res.string.savings_charges
|
||||
ChargeType.LOAN -> Res.string.loan_charges
|
||||
ChargeType.SHARE -> Res.string.charges
|
||||
else -> Res.string.client_charges
|
||||
}
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
selectedSavingsAccount = (action.target as? ChargeAccountTarget.Savings)?.account,
|
||||
selectedLoanAccount = (action.target as? ChargeAccountTarget.Loan)?.account,
|
||||
selectedShareAccount = (action.target as? ChargeAccountTarget.Share)?.account,
|
||||
activeFilter = newFilter,
|
||||
showFilter = false,
|
||||
chargeType = newChargeType,
|
||||
topBarTitleResId = newTitle,
|
||||
chargeTypeId = newId,
|
||||
)
|
||||
}
|
||||
|
||||
if (previousId != newId || previousType != newChargeType) {
|
||||
loadCharges()
|
||||
} else {
|
||||
applyLocalFilter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSavingsAccounts(accounts: List<SavingAccount>) {
|
||||
if (accounts.isEmpty()) return
|
||||
updateState { state ->
|
||||
val default = accounts.firstOrNull { it.id == state.chargeTypeId }
|
||||
val shouldSelectDefault = !state.canSwitchAccounts && state.chargeType == ChargeType.SAVINGS
|
||||
|
||||
state.copy(
|
||||
savingsAccounts = accounts,
|
||||
selectedSavingsAccount = if (shouldSelectDefault) default else state.selectedSavingsAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLoanAccounts(accounts: List<LoanAccount>) {
|
||||
if (accounts.isEmpty()) return
|
||||
updateState { state ->
|
||||
val default = accounts.firstOrNull { it.id == state.chargeTypeId }
|
||||
val shouldSelectDefault = !state.canSwitchAccounts && state.chargeType == ChargeType.LOAN
|
||||
|
||||
state.copy(
|
||||
loanAccounts = accounts,
|
||||
selectedLoanAccount = if (shouldSelectDefault) default else state.selectedLoanAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateShareAccounts(accounts: List<ShareAccount>) {
|
||||
if (accounts.isEmpty()) return
|
||||
updateState { state ->
|
||||
val default = accounts.firstOrNull { it.id == state.chargeTypeId }
|
||||
val shouldSelectDefault = !state.canSwitchAccounts && state.chargeType == ChargeType.SHARE
|
||||
|
||||
state.copy(
|
||||
shareAccounts = accounts,
|
||||
selectedShareAccount = if (shouldSelectDefault) default else state.selectedShareAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAccounts(accountType: String) {
|
||||
viewModelScope.launch {
|
||||
accountsRepositoryImpl.loadAccounts(
|
||||
clientId = state.clientId,
|
||||
accountType = accountType,
|
||||
).collect { dataState ->
|
||||
if (dataState is DataState.Success) {
|
||||
val accounts = when (accountType) {
|
||||
Constants.SAVINGS_ACCOUNTS -> dataState.data.savingsAccounts.orEmpty()
|
||||
.filter { it.status?.active == true }
|
||||
|
||||
Constants.LOAN_ACCOUNTS ->
|
||||
dataState.data.loanAccounts
|
||||
.filter { it.status?.active == true }
|
||||
|
||||
Constants.SHARE_ACCOUNTS ->
|
||||
dataState.data.shareAccounts
|
||||
.filter { it.status?.active == true }
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
when (accountType) {
|
||||
Constants.SAVINGS_ACCOUNTS -> sendAction(
|
||||
ClientChargeAction.Internal.SavingsAccountsLoaded(
|
||||
accounts.filterIsInstance<SavingAccount>(),
|
||||
),
|
||||
)
|
||||
Constants.LOAN_ACCOUNTS -> sendAction(
|
||||
ClientChargeAction.Internal.LoanAccountsLoaded(
|
||||
accounts.filterIsInstance<LoanAccount>(),
|
||||
),
|
||||
)
|
||||
Constants.SHARE_ACCOUNTS -> sendAction(
|
||||
ClientChargeAction.Internal.ShareAccountsLoaded(
|
||||
accounts.filterIsInstance<ShareAccount>(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNetworkResult(isOnline: Boolean) {
|
||||
updateState { it.copy(networkStatus = isOnline) }
|
||||
if (!isOnline) {
|
||||
updateState { current ->
|
||||
if (current.uiState is ScreenUiState.Loading ||
|
||||
@ -153,70 +319,21 @@ internal class ClientChargeViewModel(
|
||||
}
|
||||
} else {
|
||||
loadCharges()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries loading charges if the network is available.
|
||||
* If the network is not available, it sets the UI state to Network.
|
||||
*/
|
||||
private fun retry() {
|
||||
viewModelScope.launch {
|
||||
if (!state.networkStatus) {
|
||||
updateState { it.copy(uiState = ScreenUiState.Network) }
|
||||
if (state.canSwitchAccounts) {
|
||||
fetchAccounts(Constants.SAVINGS_ACCOUNTS)
|
||||
fetchAccounts(Constants.LOAN_ACCOUNTS)
|
||||
fetchAccounts(Constants.SHARE_ACCOUNTS)
|
||||
} else {
|
||||
loadCharges()
|
||||
if (state.chargeType == ChargeType.SAVINGS) fetchAccounts(Constants.SAVINGS_ACCOUNTS)
|
||||
if (state.chargeType == ChargeType.LOAN) fetchAccounts(Constants.LOAN_ACCOUNTS)
|
||||
if (state.chargeType == ChargeType.SHARE) fetchAccounts(Constants.SHARE_ACCOUNTS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any active dialog.
|
||||
*/
|
||||
private fun dismissDialog() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles result of loan/savings charge API.
|
||||
*/
|
||||
private fun handleLoanOrSavingsChargesResult(result: DataState<List<Charge>>) {
|
||||
when (result) {
|
||||
is DataState.Loading -> updateState {
|
||||
it.copy(uiState = ScreenUiState.Loading)
|
||||
}
|
||||
|
||||
is DataState.Error -> updateState {
|
||||
it.copy(
|
||||
uiState = if (result.exception.cause is IOException) {
|
||||
ScreenUiState.Network
|
||||
} else {
|
||||
ScreenUiState.Error(Res.string.feature_generic_error_server)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is DataState.Success -> updateState {
|
||||
if (result.data.isEmpty()) {
|
||||
it.copy(uiState = ScreenUiState.Empty, charges = emptyList())
|
||||
} else {
|
||||
it.copy(uiState = ScreenUiState.Success, charges = result.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Handles result of Share charge API.
|
||||
*/
|
||||
|
||||
private fun handleShareChargesResult(result: DataState<List<Charge>>) {
|
||||
when (result) {
|
||||
is DataState.Loading -> updateState { it.copy(uiState = ScreenUiState.Loading) }
|
||||
|
||||
is DataState.Error -> updateState {
|
||||
it.copy(
|
||||
uiState = if (result.exception.cause is IOException) {
|
||||
@ -226,26 +343,16 @@ internal class ClientChargeViewModel(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is DataState.Success -> updateState {
|
||||
if (result.data.isEmpty()) {
|
||||
it.copy(uiState = ScreenUiState.Empty, charges = emptyList())
|
||||
} else {
|
||||
it.copy(uiState = ScreenUiState.Success, charges = result.data)
|
||||
}
|
||||
is DataState.Success -> {
|
||||
updateState { it.copy(originalCharges = result.data) }
|
||||
applyLocalFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles result of client charge API.
|
||||
*/
|
||||
private fun handleClientChargesResult(result: DataState<Page<Charge>>) {
|
||||
when (result) {
|
||||
is DataState.Loading -> updateState {
|
||||
it.copy(uiState = ScreenUiState.Loading)
|
||||
}
|
||||
|
||||
is DataState.Loading -> updateState { it.copy(uiState = ScreenUiState.Loading) }
|
||||
is DataState.Error -> updateState {
|
||||
it.copy(
|
||||
uiState = if (result.exception.cause is IOException) {
|
||||
@ -255,38 +362,41 @@ internal class ClientChargeViewModel(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is DataState.Success -> updateState {
|
||||
if (result.data.pageItems.isEmpty()) {
|
||||
it.copy(uiState = ScreenUiState.Empty, charges = emptyList())
|
||||
} else {
|
||||
it.copy(uiState = ScreenUiState.Success, charges = result.data.pageItems)
|
||||
}
|
||||
is DataState.Success -> {
|
||||
updateState { it.copy(originalCharges = result.data.pageItems) }
|
||||
applyLocalFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading charges based on the charge type.
|
||||
*/
|
||||
private fun applyLocalFilter() {
|
||||
val filter = state.activeFilter
|
||||
val originalList = state.originalCharges
|
||||
val filteredList = if (filter == ChargeFilterUtil.ALL) {
|
||||
originalList
|
||||
} else {
|
||||
originalList.filter { filter.matchCondition(it) }
|
||||
}
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
charges = filteredList,
|
||||
uiState = if (filteredList.isEmpty()) ScreenUiState.Empty else ScreenUiState.Success,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCharges() {
|
||||
updateState { it.copy(uiState = ScreenUiState.Loading) }
|
||||
|
||||
viewModelScope.launch {
|
||||
when (state.chargeType) {
|
||||
ChargeType.CLIENT -> processClientCharges()
|
||||
ChargeType.LOAN, ChargeType.SAVINGS -> processLoanOrSavingsCharges()
|
||||
ChargeType.SHARE -> processShareCharges()
|
||||
ChargeType.LOAN, ChargeType.SAVINGS, ChargeType.SHARE -> processLoanOrSavingsCharges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads charge list on refresh.
|
||||
*/
|
||||
private fun refreshCharges() = loadCharges()
|
||||
|
||||
/**
|
||||
* Processes charges when type is CLIENT.
|
||||
*/
|
||||
private fun processClientCharges() {
|
||||
viewModelScope.launch {
|
||||
clientChargeRepositoryImp.getCharges(state.clientId)
|
||||
@ -296,48 +406,27 @@ internal class ClientChargeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes charges when type is LOAN or SAVINGS.
|
||||
*/
|
||||
private fun processLoanOrSavingsCharges() {
|
||||
viewModelScope.launch {
|
||||
clientChargeRepositoryImp.getLoanOrSavingsCharges(
|
||||
state.chargeType,
|
||||
state.chargeTypeId ?: -1L,
|
||||
).collect { result ->
|
||||
sendAction(ClientChargeAction.Internal.ReceiveLoanOrSavingsChargesResult(result))
|
||||
val idToFetch = state.chargeTypeId
|
||||
if (idToFetch == null) {
|
||||
updateState { it.copy(uiState = ScreenUiState.Empty) }
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes charges when type is Share.
|
||||
* Uses the dedicated repository function we created.
|
||||
*/
|
||||
private fun processShareCharges() {
|
||||
viewModelScope.launch {
|
||||
clientChargeRepositoryImp.getShareAccountCharges(
|
||||
state.chargeTypeId ?: -1L,
|
||||
).collect { result ->
|
||||
sendAction(ClientChargeAction.Internal.ReceiveShareChargesResult(result))
|
||||
val flow = if (state.chargeType == ChargeType.SHARE) {
|
||||
clientChargeRepositoryImp.getShareAccountCharges(idToFetch)
|
||||
} else {
|
||||
clientChargeRepositoryImp.getLoanOrSavingsCharges(state.chargeType, idToFetch)
|
||||
}
|
||||
|
||||
flow.collect { result ->
|
||||
sendAction(ClientChargeAction.Internal.ReceiveLoanOrSavingsChargesResult(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the UI state of the Client Charges screen.
|
||||
*
|
||||
* @property clientId ID of the current client.
|
||||
* @property chargeType Type of charge (CLIENT, LOAN, SAVINGS).
|
||||
* @property chargeTypeId Optional ID used for LOAN or SAVINGS charge types.
|
||||
* @property isOnline Whether the device is currently connected to the internet.
|
||||
* @property isEmpty Whether there are no charges to display.
|
||||
* @property topBarTitleResId Title shown in the app bar.
|
||||
* @property dialogState Dialog state used for showing loading or error.
|
||||
* @property charges List of fetched charges.
|
||||
* @property uiState holds the state of screen
|
||||
*/
|
||||
data class ClientChargeState(
|
||||
val networkStatus: Boolean = false,
|
||||
val clientId: Long,
|
||||
@ -347,58 +436,52 @@ data class ClientChargeState(
|
||||
val isEmpty: Boolean = false,
|
||||
val topBarTitleResId: StringResource = Res.string.charges,
|
||||
val charges: List<Charge> = emptyList(),
|
||||
val originalCharges: List<Charge> = emptyList(),
|
||||
|
||||
val savingsAccounts: List<SavingAccount> = emptyList(),
|
||||
val loanAccounts: List<LoanAccount> = emptyList(),
|
||||
val shareAccounts: List<ShareAccount> = emptyList(),
|
||||
|
||||
val selectedSavingsAccount: SavingAccount? = null,
|
||||
val selectedLoanAccount: LoanAccount? = null,
|
||||
val selectedShareAccount: ShareAccount? = null,
|
||||
|
||||
val activeFilter: ChargeFilterUtil = ChargeFilterUtil.ALL,
|
||||
val showFilter: Boolean = false,
|
||||
val canSwitchAccounts: Boolean = true,
|
||||
|
||||
val dialogState: DialogState? = null,
|
||||
val uiState: ScreenUiState? = ScreenUiState.Loading,
|
||||
) {
|
||||
/**
|
||||
* Represents the possible dialog states in the UI.
|
||||
*/
|
||||
|
||||
val selectedAccountNo: String?
|
||||
get() = selectedSavingsAccount?.accountNo
|
||||
?: selectedLoanAccount?.accountNo
|
||||
?: selectedShareAccount?.accountNo
|
||||
|
||||
sealed interface DialogState {
|
||||
/** Error dialog with a message */
|
||||
data class Error(val message: String) : DialogState
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI events emitted from the ViewModel to be handled by the UI layer.
|
||||
*
|
||||
* @property ShowToast Shows a toast message.
|
||||
* @property Navigate Navigates to the charge creation screen.
|
||||
* @property OnChargeClick Triggered when a user clicks on a charge item.
|
||||
*/
|
||||
sealed interface ClientChargeEvent {
|
||||
data class ShowToast(val message: String) : ClientChargeEvent
|
||||
|
||||
data object Navigate : ClientChargeEvent
|
||||
|
||||
data class OnChargeClick(val charge: Charge) : ClientChargeEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions dispatched from the UI or internal processes.
|
||||
*
|
||||
* @property RefreshCharges Refreshes the list of charges.
|
||||
* @property OnNavigate Navigates to the charge creation screen.
|
||||
* @property OnDismissDialog Dismisses any open dialog (error/loading).
|
||||
* @property OnChargeClick Triggered when a user clicks on a charge item.
|
||||
*/
|
||||
sealed interface ClientChargeAction {
|
||||
|
||||
data object RefreshCharges : ClientChargeAction
|
||||
|
||||
data object OnNavigate : ClientChargeAction
|
||||
|
||||
data object OnDismissDialog : ClientChargeAction
|
||||
|
||||
data class OnChargeClick(val charge: Charge) : ClientChargeAction
|
||||
|
||||
data class ReceiveNetworkResult(val isOnline: Boolean) : ClientChargeAction
|
||||
|
||||
data object Retry : ClientChargeAction
|
||||
|
||||
sealed class Internal : ClientChargeAction {
|
||||
data object ToggleFilter : ClientChargeAction
|
||||
data object ClearFilter : ClientChargeAction
|
||||
|
||||
data class ApplyFilter(
|
||||
val target: ChargeAccountTarget,
|
||||
val filter: ChargeFilterUtil,
|
||||
) : ClientChargeAction
|
||||
|
||||
sealed class Internal : ClientChargeAction {
|
||||
data class ReceiveLoanOrSavingsChargesResult(
|
||||
val result: DataState<List<Charge>>,
|
||||
) : Internal()
|
||||
@ -407,8 +490,23 @@ sealed interface ClientChargeAction {
|
||||
val result: DataState<Page<Charge>>,
|
||||
) : Internal()
|
||||
|
||||
data class ReceiveShareChargesResult(
|
||||
val result: DataState<List<Charge>>,
|
||||
) : Internal()
|
||||
data class SavingsAccountsLoaded(val accounts: List<SavingAccount>) : Internal()
|
||||
data class LoanAccountsLoaded(val accounts: List<LoanAccount>) : Internal()
|
||||
data class ShareAccountsLoaded(val accounts: List<ShareAccount>) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ClientChargeEvent {
|
||||
data class ShowToast(val message: String) : ClientChargeEvent
|
||||
data object Navigate : ClientChargeEvent
|
||||
data class OnChargeClick(val charge: Charge) : ClientChargeEvent
|
||||
}
|
||||
|
||||
sealed class ChargeAccountTarget {
|
||||
|
||||
data object AllAccounts : ChargeAccountTarget()
|
||||
|
||||
data class Savings(val account: SavingAccount) : ChargeAccountTarget()
|
||||
data class Loan(val account: LoanAccount) : ChargeAccountTarget()
|
||||
data class Share(val account: ShareAccount) : ChargeAccountTarget()
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||
*/
|
||||
package org.mifos.mobile.feature.charge.components
|
||||
|
||||
import mifos_mobile.feature.client_charge.generated.resources.Res
|
||||
import mifos_mobile.feature.client_charge.generated.resources.charges
|
||||
import mifos_mobile.feature.client_charge.generated.resources.outstanding
|
||||
import mifos_mobile.feature.client_charge.generated.resources.paid
|
||||
import mifos_mobile.feature.client_charge.generated.resources.waived
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.mifos.mobile.core.model.entity.Charge
|
||||
|
||||
/**
|
||||
* Enum class representing different filters that can be applied to Charges.
|
||||
*/
|
||||
enum class ChargeFilterUtil(
|
||||
val label: StringResource,
|
||||
val matchCondition: (Charge) -> Boolean,
|
||||
) {
|
||||
|
||||
ALL(
|
||||
label = Res.string.charges,
|
||||
matchCondition = { true },
|
||||
),
|
||||
|
||||
PAID(
|
||||
label = Res.string.paid,
|
||||
matchCondition = { it.paid },
|
||||
),
|
||||
|
||||
PENDING(
|
||||
label = Res.string.outstanding,
|
||||
matchCondition = { !it.paid && !it.waived },
|
||||
),
|
||||
|
||||
WAIVED(
|
||||
label = Res.string.waived,
|
||||
matchCondition = { it.waived },
|
||||
),
|
||||
}
|
||||
@ -26,9 +26,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import mifos_mobile.feature.client_charge.generated.resources.Res
|
||||
import mifos_mobile.feature.client_charge.generated.resources.amount_due
|
||||
import mifos_mobile.feature.client_charge.generated.resources.amount_paid
|
||||
import mifos_mobile.feature.client_charge.generated.resources.database_checkmark
|
||||
import mifos_mobile.feature.client_charge.generated.resources.database_warning
|
||||
import mifos_mobile.feature.client_charge.generated.resources.error_no_charge
|
||||
import mifos_mobile.feature.client_charge.generated.resources.feature_client_charges_charge_id
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import org.mifos.mobile.core.common.CurrencyFormatter
|
||||
import org.mifos.mobile.core.common.DateHelper
|
||||
@ -81,7 +86,7 @@ fun ClientChargeItem(
|
||||
.padding(KptTheme.spacing.sm),
|
||||
|
||||
)
|
||||
Spacer(Modifier.width(DesignToken.padding.medium))
|
||||
Spacer(Modifier.width(KptTheme.spacing.md))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
@ -91,19 +96,23 @@ fun ClientChargeItem(
|
||||
)
|
||||
// TODO: in Figma account Number is there instead of charge id. Refactor it
|
||||
Text(
|
||||
text = "ChargeId : ${charge.chargeId}",
|
||||
text = stringResource(
|
||||
Res.string.feature_client_charges_charge_id,
|
||||
charge.chargeId.toString(),
|
||||
),
|
||||
style = MifosTypography.bodySmall,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = if (charge.dueDate.isNotEmpty()) {
|
||||
text = if (!charge.dueDate.isEmpty() && charge.dueDate.size >= 3) {
|
||||
DateHelper.getDateAsString(charge.dueDate.mapNotNull { it })
|
||||
} else {
|
||||
""
|
||||
stringResource(Res.string.error_no_charge)
|
||||
},
|
||||
style = MifosTypography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(DesignToken.padding.medium))
|
||||
Spacer(Modifier.width(KptTheme.spacing.md))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs),
|
||||
@ -113,9 +122,9 @@ fun ClientChargeItem(
|
||||
) {
|
||||
Text(
|
||||
text = if (charge.isChargePaid) {
|
||||
"Paid"
|
||||
stringResource(Res.string.amount_paid)
|
||||
} else {
|
||||
"Due"
|
||||
stringResource(Res.string.amount_due)
|
||||
},
|
||||
style = MifosTypography.labelSmall,
|
||||
color = if (charge.isChargePaid) {
|
||||
@ -152,7 +161,7 @@ fun ClientChargeItem(
|
||||
}
|
||||
Icon(
|
||||
imageVector = MifosIcons.ChevronRight,
|
||||
contentDescription = "",
|
||||
contentDescription = "Navigation Icon",
|
||||
modifier = Modifier.size(DesignToken.sizes.iconDp20),
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user