From be1b948e7967f10ef519bf23d334bf47d7a6cdc2 Mon Sep 17 00:00:00 2001 From: Aryan Baglane Date: Fri, 23 Jan 2026 17:54:43 +0530 Subject: [PATCH] feat(client-charge): add client charges screen accessible from home (#3049) --- .../composeResources/values-en/strings.xml | 14 + .../composeResources/values/strings.xml | 14 + .../charge/charges/ClientChargeScreen.kt | 534 +++++++++++++++--- .../charge/charges/ClientChargeViewModel.kt | 512 ++++++++++------- .../charge/components/ChargeFilterUtil.kt | 47 ++ .../charge/components/ClientChargeItem.kt | 25 +- 6 files changed, 863 insertions(+), 283 deletions(-) create mode 100644 feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ChargeFilterUtil.kt diff --git a/feature/client-charge/src/commonMain/composeResources/values-en/strings.xml b/feature/client-charge/src/commonMain/composeResources/values-en/strings.xml index 073ab0e28..f284d167c 100644 --- a/feature/client-charge/src/commonMain/composeResources/values-en/strings.xml +++ b/feature/client-charge/src/commonMain/composeResources/values-en/strings.xml @@ -22,6 +22,20 @@ Paid: Share Charges + Filter Charges + Clear All + Account Type: + Select Account: + All Accounts + Account + Charge Status: + Apply Filters + Savings + Loan + Shares + ChargeId : %1$s + + Charge Details You have successfully paid this charge completely diff --git a/feature/client-charge/src/commonMain/composeResources/values/strings.xml b/feature/client-charge/src/commonMain/composeResources/values/strings.xml index 073ab0e28..f284d167c 100644 --- a/feature/client-charge/src/commonMain/composeResources/values/strings.xml +++ b/feature/client-charge/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,20 @@ Paid: Share Charges + Filter Charges + Clear All + Account Type: + Select Account: + All Accounts + Account + Charge Status: + Apply Filters + Savings + Loan + Shares + ChargeId : %1$s + + Charge Details You have successfully paid this charge completely diff --git a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeScreen.kt b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeScreen.kt index 32ffb6089..e768cbbe9 100644 --- a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeScreen.kt +++ b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeScreen.kt @@ -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, @@ -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 = 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, + 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 { + 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() { diff --git a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeViewModel.kt b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeViewModel.kt index 0a4ca0c18..5ccf45a6d 100644 --- a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeViewModel.kt +++ b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/charges/ClientChargeViewModel.kt @@ -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( initialState = run { val chargeRoute = savedStateHandle.toRoute() - 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) { + 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) { + 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) { + 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(), + ), + ) + Constants.LOAN_ACCOUNTS -> sendAction( + ClientChargeAction.Internal.LoanAccountsLoaded( + accounts.filterIsInstance(), + ), + ) + Constants.SHARE_ACCOUNTS -> sendAction( + ClientChargeAction.Internal.ShareAccountsLoaded( + accounts.filterIsInstance(), + ), + ) + } + } + } + } + } + + 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>) { - 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>) { 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>) { 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 = emptyList(), + val originalCharges: List = emptyList(), + + val savingsAccounts: List = emptyList(), + val loanAccounts: List = emptyList(), + val shareAccounts: List = 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>, ) : Internal() @@ -407,8 +490,23 @@ sealed interface ClientChargeAction { val result: DataState>, ) : Internal() - data class ReceiveShareChargesResult( - val result: DataState>, - ) : Internal() + data class SavingsAccountsLoaded(val accounts: List) : Internal() + data class LoanAccountsLoaded(val accounts: List) : Internal() + data class ShareAccountsLoaded(val accounts: List) : 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() +} diff --git a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ChargeFilterUtil.kt b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ChargeFilterUtil.kt new file mode 100644 index 000000000..9d7f7a1e3 --- /dev/null +++ b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ChargeFilterUtil.kt @@ -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 }, + ), +} diff --git a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ClientChargeItem.kt b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ClientChargeItem.kt index bb6b0f228..4bf8b3d2e 100644 --- a/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ClientChargeItem.kt +++ b/feature/client-charge/src/commonMain/kotlin/org/mifos/mobile/feature/charge/components/ClientChargeItem.kt @@ -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), ) }