feat(client-charge): add client charges screen accessible from home (#3049)

This commit is contained in:
Aryan Baglane 2026-01-23 17:54:43 +05:30 committed by GitHub
parent 835646ce98
commit be1b948e79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 863 additions and 283 deletions

View File

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

View File

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

View File

@ -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() {

View File

@ -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()
}

View File

@ -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 },
),
}

View File

@ -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),
)
}