From 4f8eb56db3d538fe87bc90fd9454dc3d2afc3a5c Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Tue, 5 Aug 2025 23:14:08 +0530 Subject: [PATCH 01/10] feat(feature:send-money): add UPI QR code processor --- .../data/util/StandardUpiQrCodeProcessor.kt | 101 ++++++++++++++++++ .../core/model/utils/StandardUpiQrData.kt | 34 ++++++ .../mifospay/feature/qr/ScanQrViewModel.kt | 11 +- .../composeResources/values/strings.xml | 2 + .../feature/send/money/SendMoneyScreen.kt | 7 +- .../feature/send/money/SendMoneyViewModel.kt | 16 ++- .../send/money/navigation/SendNavigation.kt | 7 +- 7 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt create mode 100644 core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt new file mode 100644 index 000000000..4cd41951b --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import org.mifospay.core.model.utils.PaymentQrData +import org.mifospay.core.model.utils.StandardUpiQrData + +/** + * Standard UPI QR Code Processor + * Handles parsing of standard UPI QR codes according to UPI specification + */ +object StandardUpiQrCodeProcessor { + + /** + * Checks if the given string is a valid UPI QR code + * @param qrData The QR code data string + * @return true if it's a valid UPI QR code, false otherwise + */ + fun isValidUpiQrCode(qrData: String): Boolean { + return qrData.startsWith("upi://") || qrData.startsWith("UPI://") + } + + /** + * Parses a standard UPI QR code string + * @param qrData The QR code data string + * @return StandardUpiQrData object with parsed information + * @throws IllegalArgumentException if the QR code is invalid + */ + fun parseUpiQrCode(qrData: String): StandardUpiQrData { + if (!isValidUpiQrCode(qrData)) { + throw IllegalArgumentException("Invalid UPI QR code format") + } + + val paramsString = qrData.substringAfter("upi://").substringAfter("UPI://") + + val parts = paramsString.split("?", limit = 2) + val params = if (parts.size > 1) parseParams(parts[1]) else emptyMap() + + val payeeVpa = params["pa"] ?: throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + val payeeName = params["pn"] ?: "Unknown" + + val vpaParts = payeeVpa.split("@", limit = 2) + val actualVpa = if (vpaParts.size == 2) payeeVpa else payeeVpa + + return StandardUpiQrData( + payeeName = payeeName, + payeeVpa = actualVpa, + amount = params["am"] ?: "", + currency = params["cu"] ?: StandardUpiQrData.DEFAULT_CURRENCY, + transactionNote = params["tn"] ?: "", + merchantCode = params["mc"] ?: "", + transactionReference = params["tr"] ?: "", + url = params["url"] ?: "", + mode = params["mode"] ?: "02", + ) + } + + /** + * Parses URL parameters into a map + * @param paramsString The parameters string + * @return Map of parameter keys and values + */ + private fun parseParams(paramsString: String): Map { + return paramsString + .split("&") + .associate { param -> + val keyValue = param.split("=", limit = 2) + if (keyValue.size == 2) { + keyValue[0] to keyValue[1] + } else { + param to "" + } + } + } + + /** + * Converts StandardUpiQrData to PaymentQrData for compatibility with existing code + * @param standardData Standard UPI QR data + * @return PaymentQrData object + * Note: clientId and accountId not available in standard UPI + */ + fun toPaymentQrData(standardData: StandardUpiQrData): PaymentQrData { + return PaymentQrData( + clientId = 0, + clientName = standardData.payeeName, + accountNo = standardData.payeeVpa, + amount = standardData.amount, + accountId = 0, + currency = standardData.currency, + officeId = 1, + accountTypeId = 2, + ) + } +} diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt new file mode 100644 index 000000000..861d4c6bb --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.utils + +import kotlinx.serialization.Serializable + +/** + * Data class representing standard UPI QR code data + * Based on UPI QR code specification + */ +@Serializable +data class StandardUpiQrData( + val payeeName: String, + val payeeVpa: String, + val amount: String = "", + val currency: String = "INR", + val transactionNote: String = "", + val merchantCode: String = "", + val transactionReference: String = "", + val url: String = "", + // 02 for QR code + val mode: String = "02", +) { + companion object { + const val DEFAULT_CURRENCY = "INR" + } +} diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index cea7f82b7..286d076b7 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor class ScanQrViewModel : ViewModel() { @@ -22,7 +23,15 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - UpiQrCodeProcessor.decodeUpiString(data) + try { + UpiQrCodeProcessor.decodeUpiString(data) + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(data) + } else { + throw e + } + } _eventFlow.update { ScanQrEvent.OnNavigateToSendScreen(data) diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64d..c824b9123 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,6 @@ Account cannot be empty Requesting payment QR but found - %1$s Failed to request payment QR: required data is missing + UPI QR code parsed successfully + External UPI Payment \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 66d7bebd2..87a3552e7 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -109,6 +108,11 @@ fun SendMoneyScreen( } is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() + + is SendMoneyEvent.ShowToast -> { + // TODO: Implement toast message display + // For now, we'll just ignore it + } } } @@ -130,7 +134,6 @@ fun SendMoneyScreen( ) } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun SendMoneyScreen( state: SendMoneyState, diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index 3ee69208a..3e07b4766 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -33,11 +33,13 @@ import mobile_wallet.feature.send_money.generated.resources.feature_send_money_e import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_invalid_amount import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_but_found import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_data_missing +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_upi_qr_parsed_successfully import org.jetbrains.compose.resources.StringResource import org.mifospay.core.common.DataState import org.mifospay.core.common.getSerialized import org.mifospay.core.common.setSerialized import org.mifospay.core.data.repository.AccountRepository +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor import org.mifospay.core.model.search.AccountResult import org.mifospay.core.model.utils.PaymentQrData @@ -176,7 +178,16 @@ class SendMoneyViewModel( private fun handleRequestData(action: HandleRequestData) { viewModelScope.launch { try { - val requestData = UpiQrCodeProcessor.decodeUpiString(action.requestData) + val requestData = try { + UpiQrCodeProcessor.decodeUpiString(action.requestData) + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(action.requestData)) { + val standardData = StandardUpiQrCodeProcessor.parseUpiQrCode(action.requestData) + StandardUpiQrCodeProcessor.toPaymentQrData(standardData) + } else { + throw e + } + } mutableStateFlow.update { state -> state.copy( @@ -185,6 +196,8 @@ class SendMoneyViewModel( selectedAccount = requestData.toAccount(), ) } + + sendEvent(SendMoneyEvent.ShowToast(Res.string.feature_send_money_upi_qr_parsed_successfully)) } catch (e: Exception) { val errorState = if (action.requestData.isNotEmpty()) { Error.GenericResourceMessage( @@ -260,6 +273,7 @@ sealed interface SendMoneyEvent { data object OnNavigateBack : SendMoneyEvent data class NavigateToTransferScreen(val data: String) : SendMoneyEvent data object NavigateToScanQrScreen : SendMoneyEvent + data class ShowToast(val message: StringResource) : SendMoneyEvent } sealed interface SendMoneyAction { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 04af30a0a..c1ddf3e8e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -14,6 +14,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.NavType import androidx.navigation.navArgument +import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions import org.mifospay.feature.send.money.SendMoneyScreen @@ -54,9 +55,9 @@ fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) { val route = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG=$requestData" - val options = navOptions ?: NavOptions.Builder() - .setPopUpTo(SEND_MONEY_ROUTE, inclusive = true) - .build() + val options = navOptions ?: navOptions { + popUpTo(SEND_MONEY_ROUTE) { inclusive = true } + } navigate(route, options) } From 001e2604377d1fb392e323eeb1822643a425670b Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Wed, 13 Aug 2025 22:59:19 +0530 Subject: [PATCH 02/10] feat(feature:send-money): add screen for payment options --- cmp-android/prodRelease-badging.txt | 2 +- .../shared/navigation/MifosNavHost.kt | 31 +- .../make/transfer/MakeTransferViewModel.kt | 6 +- .../composeResources/values/strings.xml | 8 + .../send/money/SendMoneyOptionsScreen.kt | 454 ++++++++++++++++++ .../send/money/SendMoneyOptionsViewModel.kt | 67 +++ .../feature/send/money/SendMoneyViewModel.kt | 15 +- .../feature/send/money/di/SendMoneyModule.kt | 2 + .../send/money/navigation/SendNavigation.kt | 29 ++ 9 files changed, 599 insertions(+), 15 deletions(-) create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index b57f9c278..35f0ec441 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.2-beta.0.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index d6a94277b..35db493a4 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -72,7 +72,10 @@ import org.mifospay.feature.savedcards.details.cardDetailRoute import org.mifospay.feature.savedcards.details.navigateToCardDetails import org.mifospay.feature.send.money.SendMoneyScreen import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE +import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE +import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen import org.mifospay.feature.standing.instruction.StandingInstructionsScreen @@ -160,7 +163,7 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -279,6 +282,32 @@ internal fun MifosNavHost( navigateBack = navController::navigateUp, ) + sendMoneyOptionsScreen( + onBackClick = navController::popBackStack, + onScanQrClick = { + // This is now handled by the ViewModel using ML Kit scanner + }, + onPayAnyoneClick = { + // TODO: Navigate to Pay Anyone screen + }, + onBankTransferClick = { + // TODO: Navigate to Bank Transfer screen + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onQrCodeScanned = { qrData -> + navController.navigateToSendMoneyScreen( + requestData = qrData, + navOptions = navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { + inclusive = true + } + }, + ) + }, + ) + sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt index 7df3fb44d..fe7c7a09d 100644 --- a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.make_transfer.generated.resources.Res import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_amount import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_description @@ -207,7 +208,7 @@ internal data class MakeTransferState( val amount: String = toClientData.amount, val description: String = "", val selectedAccount: Account? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && amount.toDoubleOrNull() != null @@ -232,12 +233,9 @@ internal data class MakeTransferState( transferDate = DateHelper.formattedShortDate, ) - @Serializable sealed interface DialogState { - @Serializable data object Loading : DialogState - @Serializable sealed interface Error : DialogState { data class StringMessage(val message: String) : Error data class ResourceMessage(val message: StringResource) : Error diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index c824b9123..ccdfcdb74 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -40,4 +40,12 @@ Failed to request payment QR: required data is missing UPI QR code parsed successfully External UPI Payment + Choose how you want to send money + Scan any QR code + Pay anyone + Bank Transfer + Fineract Payments + People + Merchants + More \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt new file mode 100644 index 000000000..95a675fd6 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,454 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_choose_method +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_fineract_payments +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_merchants +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_more +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_pay_anyone +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_people +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_scan_qr_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_send +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun SendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SendMoneyOptionsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + SendMoneyOptionsEvent.NavigateBack -> onBackClick.invoke() + SendMoneyOptionsEvent.NavigateToPayAnyone -> onPayAnyoneClick.invoke() + SendMoneyOptionsEvent.NavigateToBankTransfer -> onBankTransferClick.invoke() + SendMoneyOptionsEvent.NavigateToFineractPayments -> onFineractPaymentsClick.invoke() + is SendMoneyOptionsEvent.QrCodeScanned -> onQrCodeScanned.invoke(event.data) + } + } + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_send), + backPress = { + viewModel.trySendAction(SendMoneyOptionsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyBanner() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + SendMoneyOptionsRow( + onScanQrClick = { + viewModel.trySendAction(SendMoneyOptionsAction.ScanQrClicked) + }, + onPayAnyoneClick = { + viewModel.trySendAction(SendMoneyOptionsAction.PayAnyoneClicked) + }, + onBankTransferClick = { + viewModel.trySendAction(SendMoneyOptionsAction.BankTransferClicked) + }, + onFineractPaymentsClick = { + viewModel.trySendAction(SendMoneyOptionsAction.FineractPaymentsClicked) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PeopleSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + MerchantsSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + } + } + } +} + +@Composable +private fun SendMoneyBanner( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.xl), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_send_money_choose_method), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } +} + +@Composable +private fun SendMoneyOptionsRow( + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.Scan, + label = stringResource(Res.string.feature_send_money_scan_qr_code), + onClick = onScanQrClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Person, + label = stringResource(Res.string.feature_send_money_pay_anyone), + onClick = onPayAnyoneClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Bank, + label = stringResource(Res.string.feature_send_money_bank_transfer), + onClick = onBankTransferClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Payment, + label = stringResource(Res.string.feature_send_money_fineract_payments), + onClick = onFineractPaymentsClick, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun SendMoneyOptionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { onClick() }, + color = KptTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(56.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(KptTheme.spacing.sm), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + text = label, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} + +@Composable +private fun PeopleSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. People functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_people), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "John Doe", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Jane Smith", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Mike Johnson", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Sarah Wilson", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "David Brown", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Lisa Davis", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Tom Miller", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun MerchantsSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. Merchants functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_merchants), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Coffee Shop", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Grocery Store", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Restaurant", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Gas Station", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Pharmacy", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bookstore", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bakery", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun PersonItem( + name: String, + isMoreButton: Boolean = false, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { /* TODO: Handle click */ } + .clip(RoundedCornerShape(KptTheme.spacing.sm)), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = if (isMoreButton) { + KptTheme.colorScheme.secondaryContainer + } else { + KptTheme.colorScheme.primaryContainer + }, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isMoreButton) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = name, + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.onSecondaryContainer, + ) + } else { + Text( + text = name.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Text( + text = name, + style = KptTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt new file mode 100644 index 000000000..46979fc4a --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.mifospay.core.ui.utils.BaseViewModel + +class SendMoneyOptionsViewModel( + private val scanner: QrScanner, +) : BaseViewModel( + initialState = SendMoneyOptionsState(), +) { + + override fun handleAction(action: SendMoneyOptionsAction) { + when (action) { + is SendMoneyOptionsAction.NavigateBack -> { + sendEvent(SendMoneyOptionsEvent.NavigateBack) + } + is SendMoneyOptionsAction.ScanQrClicked -> { + // Use ML Kit QR scanner directly + scanner.startScanning().onEach { data -> + data?.let { result -> + sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + } + }.launchIn(viewModelScope) + } + is SendMoneyOptionsAction.PayAnyoneClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToPayAnyone) + } + is SendMoneyOptionsAction.BankTransferClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToBankTransfer) + } + is SendMoneyOptionsAction.FineractPaymentsClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToFineractPayments) + } + } + } +} + +data class SendMoneyOptionsState( + val isLoading: Boolean = false, +) + +sealed interface SendMoneyOptionsEvent { + data object NavigateBack : SendMoneyOptionsEvent + data object NavigateToPayAnyone : SendMoneyOptionsEvent + data object NavigateToBankTransfer : SendMoneyOptionsEvent + data object NavigateToFineractPayments : SendMoneyOptionsEvent + data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent +} + +sealed interface SendMoneyOptionsAction { + data object NavigateBack : SendMoneyOptionsAction + data object ScanQrClicked : SendMoneyOptionsAction + data object PayAnyoneClicked : SendMoneyOptionsAction + data object BankTransferClicked : SendMoneyOptionsAction + data object FineractPaymentsClicked : SendMoneyOptionsAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index 3e07b4766..ceacb65df 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -25,8 +25,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.send_money.generated.resources.Res import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_account_cannot_be_empty import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_amount_cannot_be_empty @@ -223,7 +223,7 @@ data class SendMoneyState( val amount: String = "", val accountNumber: String = "", val selectedAccount: AccountResult? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && @@ -242,19 +242,16 @@ data class SendMoneyState( amount = amount, ) - @Serializable sealed interface DialogState { - @Serializable + data object Loading : DialogState - @Serializable sealed interface Error : DialogState { - @Serializable - data class ResourceMessage(@Contextual val message: StringResource) : Error - @Serializable + data class ResourceMessage(val message: StringResource) : Error + data class GenericResourceMessage( - @Contextual val message: StringResource, + val message: StringResource, val args: List, ) : Error } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index 16dd21815..421f314c3 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -12,9 +12,11 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import org.mifospay.feature.send.money.ScannerModule +import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + viewModelOf(::SendMoneyOptionsViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index c1ddf3e8e..d3a5314af 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -16,6 +16,7 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen const val SEND_MONEY_ROUTE = "send_money_route" @@ -23,10 +24,16 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" +const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" + fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_ROUTE, navOptions) +fun NavController.navigateToSendMoneyOptionsScreen( + navOptions: NavOptions? = null, +) = navigate(SEND_MONEY_OPTIONS_ROUTE, navOptions) + fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, @@ -50,6 +57,28 @@ fun NavGraphBuilder.sendMoneyScreen( } } +fun NavGraphBuilder.sendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onQrCodeScanned = onQrCodeScanned, + ) + } +} + fun NavController.navigateToSendMoneyScreen( requestData: String, navOptions: NavOptions? = null, From c7e26d04dbffb75ab255ca5f5dc0cd06446dcabb Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Thu, 14 Aug 2025 08:13:39 +0530 Subject: [PATCH 03/10] feat(feature:send-money): fit names in one line --- .../org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt index 95a675fd6..c16e42aae 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import mobile_wallet.feature.send_money.generated.resources.Res @@ -447,7 +448,8 @@ private fun PersonItem( fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, color = KptTheme.colorScheme.onSurface, - maxLines = 2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From e20f6f8e2cde930004224f18f242298ada698ee6 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Thu, 14 Aug 2025 20:21:53 +0530 Subject: [PATCH 04/10] feat(feature:send-money): implement upi and non upi scanner navigation --- cmp-android/prodRelease-badging.txt | 2 +- .../shared/navigation/MifosNavHost.kt | 27 ++ .../core/designsystem/icon/MifosIcons.kt | 2 + .../mifospay/feature/qr/ScanQrCodeScreen.kt | 5 + .../mifospay/feature/qr/ScanQrViewModel.kt | 13 +- .../feature/qr/navigation/ReadQrNavigation.kt | 2 + .../feature/send/money/QrScanner.android.kt | 7 +- .../feature/send/money/PayeeDetailsScreen.kt | 272 ++++++++++++++++++ .../send/money/PayeeDetailsViewModel.kt | 95 ++++++ .../send/money/SendMoneyOptionsScreen.kt | 24 +- .../send/money/SendMoneyOptionsViewModel.kt | 14 +- .../feature/send/money/SendMoneyScreen.kt | 5 + .../feature/send/money/SendMoneyViewModel.kt | 9 +- .../feature/send/money/di/SendMoneyModule.kt | 2 + .../send/money/navigation/SendNavigation.kt | 43 +++ 15 files changed, 507 insertions(+), 15 deletions(-) create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 35f0ec441..0080f4765 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index 35db493a4..4d63084ca 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -73,8 +73,10 @@ import org.mifospay.feature.savedcards.details.navigateToCardDetails import org.mifospay.feature.send.money.SendMoneyScreen import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE +import org.mifospay.feature.send.money.navigation.navigateToPayeeDetailsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.payeeDetailsScreen import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen @@ -100,6 +102,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -306,14 +309,28 @@ internal fun MifosNavHost( }, ) }, + onNavigateToPayeeDetails = { qrCodeData -> + navController.navigateToPayeeDetailsScreen(qrCodeData) + }, ) sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, + navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen, navigateToScanQrScreen = navController::navigateToScanQr, ) + payeeDetailsScreen( + onBackClick = navController::popBackStack, + onNavigateToUpiPayment = { state -> + // TODO: Handle UPI payment navigation + }, + onNavigateToFineractPayment = { state -> + // TODO: Handle Fineract payment navigation + }, + ) + transferScreen( navigateBack = navController::popBackStack, onTransferSuccess = { @@ -351,6 +368,16 @@ internal fun MifosNavHost( }, ) }, + navigateToPayeeDetailsScreen = { + navController.navigateToPayeeDetailsScreen( + qrCodeData = it, + navOptions = navOptions { + popUpTo(SCAN_QR_ROUTE) { + inclusive = true + } + }, + ) + }, ) merchantTransferScreen( diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index 5bca46905..72d73fe1f 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CurrencyRupee import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit @@ -129,4 +130,5 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + val Currency = Icons.Filled.CurrencyRupee } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt index 66513f281..0cbfbadb1 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt @@ -30,6 +30,7 @@ import org.mifospay.core.designsystem.component.MifosScaffold internal fun ScanQrCodeScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, modifier: Modifier = Modifier, viewModel: ScanQrViewModel = koinViewModel(), ) { @@ -44,6 +45,10 @@ internal fun ScanQrCodeScreen( navigateToSendScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToSendScreen).data) } + is ScanQrEvent.OnNavigateToPayeeDetails -> { + navigateToPayeeDetailsScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToPayeeDetails).data) + } + is ScanQrEvent.ShowToast -> { scope.launch { snackbarHostState.showSnackbar((eventFlow as ScanQrEvent.ShowToast).message) diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index 286d076b7..33b8d7e20 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -23,18 +23,24 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - try { + val isUpiQr = try { UpiQrCodeProcessor.decodeUpiString(data) + true } catch (e: Exception) { if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { StandardUpiQrCodeProcessor.parseUpiQrCode(data) + true } else { - throw e + false } } _eventFlow.update { - ScanQrEvent.OnNavigateToSendScreen(data) + if (isUpiQr) { + ScanQrEvent.OnNavigateToPayeeDetails(data) + } else { + ScanQrEvent.OnNavigateToSendScreen(data) + } } true @@ -49,5 +55,6 @@ class ScanQrViewModel : ViewModel() { sealed interface ScanQrEvent { data class OnNavigateToSendScreen(val data: String) : ScanQrEvent + data class OnNavigateToPayeeDetails(val data: String) : ScanQrEvent data class ShowToast(val message: String) : ScanQrEvent } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt index 89bbe6b19..c8a3e25dd 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt @@ -23,11 +23,13 @@ fun NavController.navigateToScanQr(navOptions: NavOptions? = null) = fun NavGraphBuilder.scanQrScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, ) { composableWithSlideTransitions(route = SCAN_QR_ROUTE) { ScanQrCodeScreen( navigateBack = navigateBack, navigateToSendScreen = navigateToSendScreen, + navigateToPayeeDetailsScreen = navigateToPayeeDetailsScreen, ) } } diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt index 135592b86..5d2e1ff55 100644 --- a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt @@ -39,11 +39,12 @@ class QrScannerImp( override fun startScanning(): Flow { return callbackFlow { scanner.startScan() - .addOnSuccessListener { + .addOnSuccessListener { barcode -> launch { - send(it.rawValue) + val rawValue = barcode.rawValue + send(rawValue) } - }.addOnFailureListener { + }.addOnFailureListener { exception -> launch { send(null) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt new file mode 100644 index 000000000..8c504a981 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun PayeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, + modifier: Modifier = Modifier, + viewModel: PayeeDetailsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + PayeeDetailsEvent.NavigateBack -> onBackClick.invoke() + is PayeeDetailsEvent.NavigateToUpiPayment -> onNavigateToUpiPayment.invoke(event.state) + is PayeeDetailsEvent.NavigateToFineractPayment -> onNavigateToFineractPayment.invoke(event.state) + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = "Payee Details", + backPress = { + viewModel.trySendAction(PayeeDetailsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PayeeProfileSection(state) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + PaymentDetailsSection( + state = state, + onAmountChange = { amount -> + viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) + }, + onNoteChange = { note -> + viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + + ProceedButton( + state = state, + onProceedClick = { + viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + } + } +} + +@Composable +private fun PayeeProfileSection( + state: PayeeDetailsState, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(80.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + if (state.payeeName.isNotEmpty()) { + Text( + text = state.payeeName, + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + val contactInfo = if (state.isUpiCode) { + state.upiId + } else { + state.phoneNumber + } + + if (contactInfo.isNotEmpty()) { + Text( + text = contactInfo, + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PaymentDetailsSection( + state: PayeeDetailsState, + onAmountChange: (String) -> Unit, + onNoteChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Text( + text = "Payment Details", + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + OutlinedTextField( + value = state.amount, + onValueChange = onAmountChange, + label = { Text("Amount") }, + enabled = state.isAmountEditable, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon( + imageVector = MifosIcons.Currency, + contentDescription = "Amount", + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + + OutlinedTextField( + value = state.note, + onValueChange = { newValue -> + if (newValue.length <= 50) { + onNoteChange(newValue) + } + }, + placeholder = { Text("Add note") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2, + singleLine = false, + ) + } + } +} + +@Composable +private fun ProceedButton( + state: PayeeDetailsState, + onProceedClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isAmountValid = state.amount.isNotEmpty() && state.amount.toDoubleOrNull() != null + val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() + + Button( + onClick = onProceedClick, + enabled = isAmountValid && isContactValid, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + contentColor = KptTheme.colorScheme.onPrimary, + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) { + Text( + text = if (state.isUpiCode) "Proceed to UPI Payment" else "Proceed to Payment", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = KptTheme.spacing.sm), + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt new file mode 100644 index 000000000..8607c65d1 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BaseViewModel + +class PayeeDetailsViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = PayeeDetailsState(), +) { + + init { + val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" + + if (safeQrCodeDataString.isNotEmpty()) { + // Restore & characters that were replaced for safe navigation + val qrCodeDataString = safeQrCodeDataString.replace("___AMP___", "&") + val qrCodeData = if (StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(qrCodeDataString) + } else { + // For non-UPI QR codes, create a basic StandardUpiQrData + StandardUpiQrCodeProcessor.parseUpiQrCode("upi://pay?pa=$qrCodeDataString&pn=Unknown") + } + + mutableStateFlow.update { + it.copy( + payeeName = qrCodeData.payeeName, + upiId = qrCodeData.payeeVpa, + phoneNumber = "", + amount = qrCodeData.amount, + note = qrCodeData.transactionNote, + isAmountEditable = qrCodeData.amount.isEmpty(), + isUpiCode = true, + ) + } + } + } + + override fun handleAction(action: PayeeDetailsAction) { + when (action) { + is PayeeDetailsAction.NavigateBack -> { + sendEvent(PayeeDetailsEvent.NavigateBack) + } + is PayeeDetailsAction.UpdateAmount -> { + mutableStateFlow.value = stateFlow.value.copy(amount = action.amount) + } + is PayeeDetailsAction.UpdateNote -> { + mutableStateFlow.value = stateFlow.value.copy(note = action.note) + } + is PayeeDetailsAction.ProceedToPayment -> { + val currentState = stateFlow.value + if (currentState.isUpiCode) { + sendEvent(PayeeDetailsEvent.NavigateToUpiPayment(currentState)) + } else { + sendEvent(PayeeDetailsEvent.NavigateToFineractPayment(currentState)) + } + } + } + } +} + +data class PayeeDetailsState( + val payeeName: String = "", + val upiId: String = "", + val phoneNumber: String = "", + val amount: String = "", + val note: String = "", + val isAmountEditable: Boolean = true, + val isUpiCode: Boolean = false, + val isLoading: Boolean = false, +) + +sealed interface PayeeDetailsEvent { + data object NavigateBack : PayeeDetailsEvent + data class NavigateToUpiPayment(val state: PayeeDetailsState) : PayeeDetailsEvent + data class NavigateToFineractPayment(val state: PayeeDetailsState) : PayeeDetailsEvent +} + +sealed interface PayeeDetailsAction { + data object NavigateBack : PayeeDetailsAction + data class UpdateAmount(val amount: String) : PayeeDetailsAction + data class UpdateNote(val note: String) : PayeeDetailsAction + data object ProceedToPayment : PayeeDetailsAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt index c16e42aae..aaa28a8c1 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -67,6 +67,7 @@ fun SendMoneyOptionsScreen( onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, modifier: Modifier = Modifier, viewModel: SendMoneyOptionsViewModel = koinViewModel(), ) { @@ -74,11 +75,24 @@ fun SendMoneyOptionsScreen( EventsEffect(viewModel) { event -> when (event) { - SendMoneyOptionsEvent.NavigateBack -> onBackClick.invoke() - SendMoneyOptionsEvent.NavigateToPayAnyone -> onPayAnyoneClick.invoke() - SendMoneyOptionsEvent.NavigateToBankTransfer -> onBankTransferClick.invoke() - SendMoneyOptionsEvent.NavigateToFineractPayments -> onFineractPaymentsClick.invoke() - is SendMoneyOptionsEvent.QrCodeScanned -> onQrCodeScanned.invoke(event.data) + SendMoneyOptionsEvent.NavigateBack -> { + onBackClick.invoke() + } + SendMoneyOptionsEvent.NavigateToPayAnyone -> { + onPayAnyoneClick.invoke() + } + SendMoneyOptionsEvent.NavigateToBankTransfer -> { + onBankTransferClick.invoke() + } + SendMoneyOptionsEvent.NavigateToFineractPayments -> { + onFineractPaymentsClick.invoke() + } + is SendMoneyOptionsEvent.QrCodeScanned -> { + onQrCodeScanned.invoke(event.data) + } + is SendMoneyOptionsEvent.NavigateToPayeeDetails -> { + onNavigateToPayeeDetails.invoke(event.qrCodeData) + } } } MifosGradientBackground { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt index 46979fc4a..0e82e041e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -12,6 +12,8 @@ package org.mifospay.feature.send.money import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BackgroundEvent import org.mifospay.core.ui.utils.BaseViewModel class SendMoneyOptionsViewModel( @@ -29,7 +31,14 @@ class SendMoneyOptionsViewModel( // Use ML Kit QR scanner directly scanner.startScanning().onEach { data -> data?.let { result -> - sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + // Check if it's a UPI QR code or regular QR code + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + // Navigate to payee details screen for UPI QR codes + sendEvent(SendMoneyOptionsEvent.NavigateToPayeeDetails(result)) + } else { + // For non-UPI QR codes, navigate to Fineract payment + sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + } } }.launchIn(viewModelScope) } @@ -55,7 +64,8 @@ sealed interface SendMoneyOptionsEvent { data object NavigateToPayAnyone : SendMoneyOptionsEvent data object NavigateToBankTransfer : SendMoneyOptionsEvent data object NavigateToFineractPayments : SendMoneyOptionsEvent - data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent + data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent, BackgroundEvent + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyOptionsEvent, BackgroundEvent } sealed interface SendMoneyOptionsAction { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 87a3552e7..cd49c6f32 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt @@ -91,6 +91,7 @@ import template.core.base.designsystem.theme.KptTheme fun SendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetails: (String) -> Unit, navigateToScanQrScreen: () -> Unit, showTopBar: Boolean = true, modifier: Modifier = Modifier, @@ -107,6 +108,10 @@ fun SendMoneyScreen( navigateToTransferScreen(event.data) } + is SendMoneyEvent.NavigateToPayeeDetails -> { + navigateToPayeeDetails(event.qrCodeData) + } + is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() is SendMoneyEvent.ShowToast -> { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index ceacb65df..6a9e9bb73 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -44,6 +44,7 @@ import org.mifospay.core.data.util.UpiQrCodeProcessor import org.mifospay.core.model.search.AccountResult import org.mifospay.core.model.utils.PaymentQrData import org.mifospay.core.model.utils.toAccount +import org.mifospay.core.ui.utils.BackgroundEvent import org.mifospay.core.ui.utils.BaseViewModel import org.mifospay.feature.send.money.SendMoneyAction.HandleRequestData import org.mifospay.feature.send.money.SendMoneyState.DialogState.Error @@ -122,7 +123,11 @@ class SendMoneyViewModel( SendMoneyAction.OnClickScan -> { scanner.startScanning().onEach { data -> data?.let { result -> - sendAction(HandleRequestData(result)) + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + sendEvent(SendMoneyEvent.NavigateToPayeeDetails(result)) + } else { + sendAction(HandleRequestData(result)) + } } }.launchIn(viewModelScope) // Using Play Service Code Scanner until Qr Scan module is stable @@ -270,6 +275,8 @@ sealed interface SendMoneyEvent { data object OnNavigateBack : SendMoneyEvent data class NavigateToTransferScreen(val data: String) : SendMoneyEvent data object NavigateToScanQrScreen : SendMoneyEvent + + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyEvent, BackgroundEvent data class ShowToast(val message: StringResource) : SendMoneyEvent } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index 421f314c3..8af69abde 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -11,6 +11,7 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.send.money.PayeeDetailsViewModel import org.mifospay.feature.send.money.ScannerModule import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel @@ -19,4 +20,5 @@ val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) viewModelOf(::SendMoneyOptionsViewModel) + viewModelOf(::PayeeDetailsViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index d3a5314af..3d4322496 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -16,6 +16,8 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.PayeeDetailsScreen +import org.mifospay.feature.send.money.PayeeDetailsState import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen @@ -25,6 +27,10 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" +const val PAYEE_DETAILS_ROUTE = "payee_details_route" +const val PAYEE_DETAILS_ARG = "qrCodeData" + +const val PAYEE_DETAILS_BASE_ROUTE = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG={$PAYEE_DETAILS_ARG}" fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, @@ -34,9 +40,21 @@ fun NavController.navigateToSendMoneyOptionsScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_OPTIONS_ROUTE, navOptions) +fun NavController.navigateToPayeeDetailsScreen( + qrCodeData: String, + navOptions: NavOptions? = null, +) { + val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$qrCodeData" + val options = navOptions ?: navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { inclusive = false } + } + navigate(route, options) +} + fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, navigateToScanQrScreen: () -> Unit, ) { composableWithSlideTransitions( @@ -53,6 +71,7 @@ fun NavGraphBuilder.sendMoneyScreen( onBackClick = onBackClick, navigateToTransferScreen = navigateToTransferScreen, navigateToScanQrScreen = navigateToScanQrScreen, + navigateToPayeeDetails = navigateToPayeeDetailsScreen, ) } } @@ -64,6 +83,7 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, ) { composableWithSlideTransitions( route = SEND_MONEY_OPTIONS_ROUTE, @@ -75,6 +95,29 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( onBankTransferClick = onBankTransferClick, onFineractPaymentsClick = onFineractPaymentsClick, onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + ) + } +} + +fun NavGraphBuilder.payeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, +) { + composableWithSlideTransitions( + route = PAYEE_DETAILS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYEE_DETAILS_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + PayeeDetailsScreen( + onBackClick = onBackClick, + onNavigateToUpiPayment = onNavigateToUpiPayment, + onNavigateToFineractPayment = onNavigateToFineractPayment, ) } } From 3c31541c14195fd4778f4f25c60d20379c5aced8 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Fri, 15 Aug 2025 11:28:04 +0530 Subject: [PATCH 05/10] fix(feature:send-money): fetch UPI data in payee details screen --- cmp-android/prodRelease-badging.txt | 2 +- .../data/util/StandardUpiQrCodeProcessor.kt | 5 +- .../feature/send/money/PayeeDetailsScreen.kt | 278 +++++++++++++++--- .../send/money/PayeeDetailsViewModel.kt | 96 +++++- .../send/money/navigation/SendNavigation.kt | 43 ++- 5 files changed, 368 insertions(+), 56 deletions(-) diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 0080f4765..2769ef610 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt index 4cd41951b..545f7b574 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -39,11 +39,12 @@ object StandardUpiQrCodeProcessor { } val paramsString = qrData.substringAfter("upi://").substringAfter("UPI://") - val parts = paramsString.split("?", limit = 2) val params = if (parts.size > 1) parseParams(parts[1]) else emptyMap() - val payeeVpa = params["pa"] ?: throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + val payeeVpa = params["pa"] ?: run { + throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + } val payeeName = params["pn"] ?: "Unknown" val vpaParts = payeeVpa.split("@", limit = 2) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt index 8c504a981..3cd48fcb3 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -9,18 +9,28 @@ */ package org.mifospay.feature.send.money +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -29,16 +39,23 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import org.mifospay.core.designsystem.component.MifosGradientBackground @@ -90,7 +107,7 @@ fun PayeeDetailsScreen( PayeeProfileSection(state) - Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) PaymentDetailsSection( state = state, @@ -146,17 +163,48 @@ private fun PayeeProfileSection( ), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = MifosIcons.Person, - contentDescription = "Payee Profile", - modifier = Modifier.size(40.dp), - tint = KptTheme.colorScheme.onPrimaryContainer, - ) + if (state.payeeName.isNotEmpty() && state.payeeName != "UNKNOWN") { + val firstLetter = state.payeeName + .replace("%20", " ") + .trim() + .firstOrNull() + ?.uppercase() + + if (firstLetter != null) { + Text( + text = firstLetter, + style = KptTheme.typography.headlineLarge.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ), + color = KptTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + ) + } else { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } else { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } } - if (state.payeeName.isNotEmpty()) { + if (state.payeeName.isNotEmpty() && state.payeeName != "UNKNOWN") { + val decodedName = state.payeeName + .replace("%20", " ") + .trim() + Text( - text = state.payeeName, + text = "Paying ${decodedName.uppercase()}", style = KptTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, color = KptTheme.colorScheme.onSurface, @@ -165,7 +213,7 @@ private fun PayeeProfileSection( } val contactInfo = if (state.isUpiCode) { - state.upiId + "UPI ID: ${state.upiId}" } else { state.phoneNumber } @@ -190,54 +238,191 @@ private fun PaymentDetailsSection( onNoteChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - Card( + Column( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = KptTheme.colorScheme.surface, - ), - shape = RoundedCornerShape(KptTheme.spacing.md), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), ) { - Column( + ExpandableAmountInput( + value = state.formattedAmount, + onValueChange = onAmountChange, + enabled = state.isAmountEditable, + modifier = Modifier.wrapContentWidth(), + ) + + AnimatedVisibility( + visible = state.showMaxAmountMessage, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)), + ) { + val vibrationOffset by animateFloatAsState( + targetValue = if (state.showMaxAmountMessage) 1f else 0f, + animationSpec = repeatable( + iterations = 3, + animation = tween(100, delayMillis = 0), + ), + label = "vibration", + ) + + Text( + text = "Amount cannot be more than ₹ 5,00,000", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.error, + modifier = Modifier + .padding(top = KptTheme.spacing.xs) + .graphicsLayer { + translationX = if (state.showMaxAmountMessage) { + (vibrationOffset * 10f * (if (vibrationOffset % 2 == 0f) 1f else -1f)) + } else { + 0f + } + }, + ) + } + + ExpandableNoteInput( + value = state.note, + onValueChange = onNoteChange, + modifier = Modifier.wrapContentWidth(), + ) + } +} + +@Composable +private fun ExpandableAmountInput( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val displayValue = value.ifEmpty { "0" } + + Column(modifier = modifier) { + Row( modifier = Modifier - .fillMaxWidth() - .padding(KptTheme.spacing.lg), - verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + .wrapContentWidth() + .clip(RoundedCornerShape(KptTheme.spacing.sm)) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) + .padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { Text( - text = "Payment Details", - style = KptTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = KptTheme.colorScheme.onSurface, + text = "₹", + style = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ), ) - OutlinedTextField( - value = state.amount, - onValueChange = onAmountChange, - label = { Text("Amount") }, - enabled = state.isAmountEditable, + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + BasicTextField( + value = displayValue, + onValueChange = { newValue -> + val cleanValue = newValue.replace(",", "").replace(".", "") + if (cleanValue.isEmpty() || cleanValue.toLongOrNull() != null) { + val amount = cleanValue.toLongOrNull() ?: 0L + if (amount <= 500000) { + onValueChange(cleanValue) + } + } + }, + enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - leadingIcon = { - Icon( - imageVector = MifosIcons.Currency, - contentDescription = "Amount", - tint = KptTheme.colorScheme.onSurfaceVariant, + textStyle = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width( + when { + displayValue.length <= 1 -> 24.dp + displayValue.length <= 3 -> displayValue.length * 16.dp + displayValue.length <= 6 -> displayValue.length * 14.dp + else -> displayValue.length * 12.dp + }, ) - }, + .focusRequester(focusRequester), + singleLine = true, ) + } + } +} + +@Composable +private fun ExpandableNoteInput( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } - OutlinedTextField( - value = state.note, + Column(modifier = modifier) { + Row( + modifier = Modifier + .wrapContentWidth() + .clip(RoundedCornerShape(KptTheme.spacing.sm)) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) + .padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = value, onValueChange = { newValue -> if (newValue.length <= 50) { - onNoteChange(newValue) + onValueChange(newValue) + } + }, + enabled = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + textStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = if (value.isEmpty()) KptTheme.colorScheme.onSurfaceVariant else KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width( + when { + value.length <= 7 -> 7 * 12.dp + value.length <= 28 -> (value.length + 1) * 12.dp + else -> 28 * 12.dp + }, + ) + .focusRequester(focusRequester), + singleLine = value.length <= 28, + maxLines = if (value.length > 28) 2 else 1, + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = "Add note", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + ) } + innerTextField() }, - placeholder = { Text("Add note") }, - modifier = Modifier.fillMaxWidth(), - maxLines = 2, - singleLine = false, ) } } @@ -249,7 +434,10 @@ private fun ProceedButton( onProceedClick: () -> Unit, modifier: Modifier = Modifier, ) { - val isAmountValid = state.amount.isNotEmpty() && state.amount.toDoubleOrNull() != null + val isAmountValid = state.amount.isNotEmpty() && + state.amount.toLongOrNull() != null && + state.amount.toLong() > 0 && + !state.isAmountExceedingMax val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() Button( diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt index 8607c65d1..e4d23ed9c 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -24,12 +24,13 @@ class PayeeDetailsViewModel( val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" if (safeQrCodeDataString.isNotEmpty()) { - // Restore & characters that were replaced for safe navigation - val qrCodeDataString = safeQrCodeDataString.replace("___AMP___", "&") - val qrCodeData = if (StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString)) { + // URL decode the QR code data to restore special characters + val qrCodeDataString = safeQrCodeDataString.urlDecode() + val isUpiCode = StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString) + + val qrCodeData = if (isUpiCode) { StandardUpiQrCodeProcessor.parseUpiQrCode(qrCodeDataString) } else { - // For non-UPI QR codes, create a basic StandardUpiQrData StandardUpiQrCodeProcessor.parseUpiQrCode("upi://pay?pa=$qrCodeDataString&pn=Unknown") } @@ -53,7 +54,18 @@ class PayeeDetailsViewModel( sendEvent(PayeeDetailsEvent.NavigateBack) } is PayeeDetailsAction.UpdateAmount -> { - mutableStateFlow.value = stateFlow.value.copy(amount = action.amount) + val cleanAmount = action.amount.replace(",", "") + val isValidAmount = cleanAmount.isEmpty() || cleanAmount.toDoubleOrNull() != null + + if (isValidAmount) { + val amountValue = cleanAmount.toDoubleOrNull() ?: 0.0 + val showMessage = amountValue > 500000 + + mutableStateFlow.value = stateFlow.value.copy( + amount = cleanAmount, + showMaxAmountMessage = showMessage, + ) + } } is PayeeDetailsAction.UpdateNote -> { mutableStateFlow.value = stateFlow.value.copy(note = action.note) @@ -79,7 +91,39 @@ data class PayeeDetailsState( val isAmountEditable: Boolean = true, val isUpiCode: Boolean = false, val isLoading: Boolean = false, -) + val showMaxAmountMessage: Boolean = false, +) { + val formattedAmount: String + get() = if (amount.isEmpty()) "0" else formatAmountWithCommas(amount) + + val isAmountExceedingMax: Boolean + get() = amount.toDoubleOrNull()?.let { it > 500000 } ?: false + + private fun formatAmountWithCommas(amountStr: String): String { + val cleanAmount = amountStr.replace(",", "") + return try { + val amount = cleanAmount.toDouble() + if (amount == 0.0) return "0" + + val parts = amount.toString().split(".") + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + val formattedInteger = integerPart.reversed() + .chunked(3) + .joinToString(",") + .reversed() + + if (decimalPart.isNotEmpty()) { + "$formattedInteger.$decimalPart" + } else { + formattedInteger + } + } catch (e: NumberFormatException) { + amountStr + } + } +} sealed interface PayeeDetailsEvent { data object NavigateBack : PayeeDetailsEvent @@ -93,3 +137,43 @@ sealed interface PayeeDetailsAction { data class UpdateNote(val note: String) : PayeeDetailsAction data object ProceedToPayment : PayeeDetailsAction } + +/** + * URL decodes a string to restore special characters from navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + * + * Note: %25 (percent) must be decoded last to avoid double decoding. + */ +private fun String.urlDecode(): String { + return this.replace("%20", " ") + .replace("%26", "&") + .replace("%3D", "=") + .replace("%3F", "?") + .replace("%40", "@") + .replace("%2B", "+") + .replace("%2F", "/") + .replace("%3A", ":") + .replace("%23", "#") + .replace("%22", "\"") + .replace("%27", "'") + .replace("%2C", ",") + .replace("%24", "$") + .replace("%3B", ";") + .replace("%5B", "[") + .replace("%5D", "]") + .replace("%7B", "{") + .replace("%7D", "}") + .replace("%25", "%") +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 3d4322496..00962212e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -44,13 +44,14 @@ fun NavController.navigateToPayeeDetailsScreen( qrCodeData: String, navOptions: NavOptions? = null, ) { - val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$qrCodeData" + // URL encode the QR code data to handle special characters like &, =, etc. + val encodedQrCodeData = qrCodeData.urlEncode() + val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$encodedQrCodeData" val options = navOptions ?: navOptions { popUpTo(SEND_MONEY_OPTIONS_ROUTE) { inclusive = false } } navigate(route, options) } - fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, @@ -133,3 +134,41 @@ fun NavController.navigateToSendMoneyScreen( navigate(route, options) } + +/** + * URL encodes a string to handle special characters in navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + */ +private fun String.urlEncode(): String { + return this.replace("%", "%25") + .replace(" ", "%20") + .replace("&", "%26") + .replace("=", "%3D") + .replace("?", "%3F") + .replace("@", "%40") + .replace("+", "%2B") + .replace("/", "%2F") + .replace(":", "%3A") + .replace("#", "%23") + .replace("\"", "%22") + .replace("'", "%27") + .replace(",", "%2C") + .replace("$", "%24") + .replace(";", "%3B") + .replace("[", "%5B") + .replace("]", "%5D") + .replace("{", "%7B") + .replace("}", "%7D") +} From fa8e9afdd4ced9eee1b94b6b470b1623b9b6678e Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Fri, 15 Aug 2025 13:26:14 +0530 Subject: [PATCH 06/10] refactor(send-money): enhance payment details UI/UX --- cmp-android/prodRelease-badging.txt | 2 +- .../core/designsystem/icon/MifosIcons.kt | 6 +- .../feature/send/money/PayeeDetailsScreen.kt | 178 ++++++++++++------ .../send/money/PayeeDetailsViewModel.kt | 31 ++- 4 files changed, 154 insertions(+), 63 deletions(-) diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 2769ef610..a7582b12e 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.5' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index 72d73fe1f..005e46a97 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -11,6 +11,7 @@ package org.mifospay.core.designsystem.icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.AttachMoney @@ -130,5 +131,8 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked - val Currency = Icons.Filled.CurrencyRupee + + val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward + + val CurrencyRupee = Icons.Filled.CurrencyRupee } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt index 3cd48fcb3..59a4fdc13 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -19,8 +19,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues 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.padding @@ -41,13 +43,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -95,40 +101,52 @@ fun PayeeDetailsScreen( ) }, ) { paddingValues -> - Column( + Box( modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .padding(horizontal = KptTheme.spacing.lg) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + .fillMaxSize() + .padding(paddingValues), ) { - Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) - PayeeProfileSection(state) + PayeeProfileSection(state) - Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xs)) - PaymentDetailsSection( - state = state, - onAmountChange = { amount -> - viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) - }, - onNoteChange = { note -> - viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) - }, - ) + PaymentDetailsSection( + state = state, + onAmountChange = { amount -> + viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) + }, + onNoteChange = { note -> + viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) + }, + onNoteFieldFocused = { + viewModel.trySendAction(PayeeDetailsAction.NoteFieldFocused) + }, + ) - Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + } ProceedButton( state = state, onProceedClick = { viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = KptTheme.spacing.lg, + bottom = KptTheme.spacing.lg, + ), ) - - Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) } } } @@ -236,6 +254,7 @@ private fun PaymentDetailsSection( state: PayeeDetailsState, onAmountChange: (String) -> Unit, onNoteChange: (String) -> Unit, + onNoteFieldFocused: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -283,11 +302,13 @@ private fun PaymentDetailsSection( ExpandableNoteInput( value = state.note, onValueChange = onNoteChange, + onFieldFocused = onNoteFieldFocused, modifier = Modifier.wrapContentWidth(), ) } } +// TODO improve amount validation and UI/UX @Composable private fun ExpandableAmountInput( value: String, @@ -298,6 +319,31 @@ private fun ExpandableAmountInput( val focusRequester = remember { FocusRequester() } val displayValue = value.ifEmpty { "0" } + /** + * Calculate width based on the display value + * When showing "0" (single digit), use minimal width + * When user enters decimal or additional digits, expand dynamically + * Maximum amount is ₹5,00,000 (6 digits + decimal + up to 2 decimal places = max 9 characters) + */ + val textFieldWidth = when { + displayValue == "0" -> 24.dp + displayValue.length == 2 -> 32.dp + displayValue.length == 3 -> 48.dp + displayValue.length == 4 -> 64.dp + displayValue.length == 5 -> 80.dp + displayValue.length == 6 -> 96.dp + displayValue.length == 7 -> 112.dp + displayValue.length == 8 -> 128.dp + displayValue.length == 9 -> 144.dp + else -> 144.dp // Maximum width for ₹5,00,000.00 + } + + LaunchedEffect(enabled) { + if (enabled) { + focusRequester.requestFocus() + } + } + Column(modifier = modifier) { Row( modifier = Modifier @@ -314,13 +360,10 @@ private fun ExpandableAmountInput( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - Text( - text = "₹", - style = TextStyle( - fontSize = 24.sp, - fontWeight = FontWeight.Medium, - color = KptTheme.colorScheme.onSurface, - ), + Icon( + imageVector = MifosIcons.CurrencyRupee, + contentDescription = "Rupee Icon", + tint = KptTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) @@ -328,16 +371,19 @@ private fun ExpandableAmountInput( BasicTextField( value = displayValue, onValueChange = { newValue -> - val cleanValue = newValue.replace(",", "").replace(".", "") - if (cleanValue.isEmpty() || cleanValue.toLongOrNull() != null) { - val amount = cleanValue.toLongOrNull() ?: 0L - if (amount <= 500000) { - onValueChange(cleanValue) - } + val cleanValue = newValue.replace(",", "") + if (cleanValue.isEmpty() || cleanValue.toDoubleOrNull() != null) { + val amount = cleanValue.toDoubleOrNull() ?: 0.0 + + /** + * Allow the input to be processed by ViewModel for error handling + * The ViewModel will show error message briefly for invalid amounts + */ + onValueChange(cleanValue) } }, enabled = enabled, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), textStyle = TextStyle( fontSize = 24.sp, fontWeight = FontWeight.Medium, @@ -345,14 +391,7 @@ private fun ExpandableAmountInput( textAlign = TextAlign.Center, ), modifier = Modifier - .width( - when { - displayValue.length <= 1 -> 24.dp - displayValue.length <= 3 -> displayValue.length * 16.dp - displayValue.length <= 6 -> displayValue.length * 14.dp - else -> displayValue.length * 12.dp - }, - ) + .width(textFieldWidth) .focusRequester(focusRequester), singleLine = true, ) @@ -360,13 +399,16 @@ private fun ExpandableAmountInput( } } +// TODO improve add note UI/UX @Composable private fun ExpandableNoteInput( value: String, onValueChange: (String) -> Unit, + onFieldFocused: () -> Unit, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } Column(modifier = modifier) { Row( @@ -406,7 +448,13 @@ private fun ExpandableNoteInput( else -> 28 * 12.dp }, ) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused && !isFocused) { + isFocused = true + onFieldFocused() + } + }, singleLine = value.length <= 28, maxLines = if (value.length > 28) 2 else 1, decorationBox = { innerTextField -> @@ -428,33 +476,51 @@ private fun ExpandableNoteInput( } } +// TODO improve UI/UX of proceed button @Composable private fun ProceedButton( state: PayeeDetailsState, onProceedClick: () -> Unit, modifier: Modifier = Modifier, ) { - val isAmountValid = state.amount.isNotEmpty() && - state.amount.toLongOrNull() != null && - state.amount.toLong() > 0 && - !state.isAmountExceedingMax + val isAmountValid = if (state.isUpiCode) { + state.amount.isNotEmpty() && + state.amount.toDoubleOrNull() != null && + state.amount.toDouble() >= 0 && + !state.isAmountExceedingMax + } else { + state.amount.isNotEmpty() && + state.amount.toDoubleOrNull() != null && + state.amount.toDouble() > 0 && + !state.isAmountExceedingMax + } val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() + val isAmountPrefilled = !state.isAmountEditable + val showCheckMark = isAmountValid && isContactValid && (isAmountPrefilled || state.hasNoteFieldBeenFocused) Button( onClick = onProceedClick, enabled = isAmountValid && isContactValid, - modifier = modifier.fillMaxWidth(), + modifier = modifier.size(56.dp), colors = ButtonDefaults.buttonColors( - containerColor = KptTheme.colorScheme.primary, - contentColor = KptTheme.colorScheme.onPrimary, + containerColor = if (isAmountValid && isContactValid) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.surfaceVariant + }, + contentColor = if (isAmountValid && isContactValid) { + KptTheme.colorScheme.onPrimary + } else { + KptTheme.colorScheme.onSurfaceVariant + }, ), shape = RoundedCornerShape(KptTheme.spacing.sm), + contentPadding = PaddingValues(0.dp), ) { - Text( - text = if (state.isUpiCode) "Proceed to UPI Payment" else "Proceed to Payment", - style = KptTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(vertical = KptTheme.spacing.sm), + Icon( + imageVector = if (showCheckMark) MifosIcons.Check else MifosIcons.ArrowForward, + contentDescription = if (showCheckMark) "Proceed" else "Next", + modifier = Modifier.size(32.dp), ) } } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt index e4d23ed9c..87baf0e9e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -10,7 +10,10 @@ package org.mifospay.feature.send.money import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.ui.utils.BaseViewModel @@ -24,7 +27,6 @@ class PayeeDetailsViewModel( val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" if (safeQrCodeDataString.isNotEmpty()) { - // URL decode the QR code data to restore special characters val qrCodeDataString = safeQrCodeDataString.urlDecode() val isUpiCode = StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString) @@ -65,11 +67,23 @@ class PayeeDetailsViewModel( amount = cleanAmount, showMaxAmountMessage = showMessage, ) + + if (showMessage) { + viewModelScope.launch { + delay(2000) + mutableStateFlow.value = stateFlow.value.copy( + showMaxAmountMessage = false, + ) + } + } } } is PayeeDetailsAction.UpdateNote -> { mutableStateFlow.value = stateFlow.value.copy(note = action.note) } + is PayeeDetailsAction.NoteFieldFocused -> { + mutableStateFlow.value = stateFlow.value.copy(hasNoteFieldBeenFocused = true) + } is PayeeDetailsAction.ProceedToPayment -> { val currentState = stateFlow.value if (currentState.isUpiCode) { @@ -92,6 +106,7 @@ data class PayeeDetailsState( val isUpiCode: Boolean = false, val isLoading: Boolean = false, val showMaxAmountMessage: Boolean = false, + val hasNoteFieldBeenFocused: Boolean = false, ) { val formattedAmount: String get() = if (amount.isEmpty()) "0" else formatAmountWithCommas(amount) @@ -103,7 +118,7 @@ data class PayeeDetailsState( val cleanAmount = amountStr.replace(",", "") return try { val amount = cleanAmount.toDouble() - if (amount == 0.0) return "0" + if (amount == 0.0) return if (isUpiCode) "0.00" else "0" val parts = amount.toString().split(".") val integerPart = parts[0] @@ -114,10 +129,15 @@ data class PayeeDetailsState( .joinToString(",") .reversed() - if (decimalPart.isNotEmpty()) { - "$formattedInteger.$decimalPart" + if (isUpiCode) { + val paddedDecimalPart = decimalPart.padEnd(2, '0').take(2) + "$formattedInteger.$paddedDecimalPart" } else { - formattedInteger + if (decimalPart.isNotEmpty()) { + "$formattedInteger.$decimalPart" + } else { + formattedInteger + } } } catch (e: NumberFormatException) { amountStr @@ -135,6 +155,7 @@ sealed interface PayeeDetailsAction { data object NavigateBack : PayeeDetailsAction data class UpdateAmount(val amount: String) : PayeeDetailsAction data class UpdateNote(val note: String) : PayeeDetailsAction + data object NoteFieldFocused : PayeeDetailsAction data object ProceedToPayment : PayeeDetailsAction } From 0c726634039061fcd70c4b0f0df6e7a1ec1350f3 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Wed, 20 Aug 2025 11:35:30 +0530 Subject: [PATCH 07/10] feat(feature:send-money): add bank transfer to others screen --- cmp-android/prodRelease-badging.txt | 2 +- .../shared/navigation/MifosNavHost.kt | 8 +- .../composeResources/values/strings.xml | 12 + .../feature/send/money/BankTransferScreen.kt | 349 ++++++++++++++++++ .../send/money/BankTransferViewModel.kt | 70 ++++ .../feature/send/money/di/SendMoneyModule.kt | 2 + .../send/money/navigation/SendNavigation.kt | 18 + 7 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 91ced69b5..522e01de8 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.2-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index 4d63084ca..63c34a28e 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -73,6 +73,8 @@ import org.mifospay.feature.savedcards.details.navigateToCardDetails import org.mifospay.feature.send.money.SendMoneyScreen import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE +import org.mifospay.feature.send.money.navigation.bankTransferScreen +import org.mifospay.feature.send.money.navigation.navigateToBankTransferScreen import org.mifospay.feature.send.money.navigation.navigateToPayeeDetailsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen @@ -294,7 +296,7 @@ internal fun MifosNavHost( // TODO: Navigate to Pay Anyone screen }, onBankTransferClick = { - // TODO: Navigate to Bank Transfer screen + navController.navigateToBankTransferScreen() }, onFineractPaymentsClick = { navController.navigateToSendMoneyScreen() @@ -321,6 +323,10 @@ internal fun MifosNavHost( navigateToScanQrScreen = navController::navigateToScanQr, ) + bankTransferScreen( + onBackClick = navController::popBackStack, + ) + payeeDetailsScreen( onBackClick = navController::popBackStack, onNavigateToUpiPayment = { state -> diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index ccdfcdb74..d77836502 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -44,8 +44,20 @@ Scan any QR code Pay anyone Bank Transfer + To Others + To Self Fineract Payments People Merchants More + + + Receiver's Bank Details + Bank account number + Input not valid + IFSC Code + Search for IFSC + Continue + This information will be securely saved as per Mifos Initiative Terms of Service and Privacy Policy + Recent Transfers \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt new file mode 100644 index 000000000..d073a2a81 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_account_number +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_account_number_error +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_details_note +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer_to_others +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer_to_self +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_continue +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_ifsc_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_receivers_bank_details +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_recent_transfers +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_search_ifsc +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTextField +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BankTransferScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: BankTransferViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var selectedTabIndex by remember { mutableIntStateOf(0) } + + EventsEffect(viewModel) { event -> + when (event) { + BankTransferEvent.NavigateBack -> { + onBackClick.invoke() + } + BankTransferEvent.ShowIfscSearch -> { + // TODO: Implement IFSC search dialog/screen + } + BankTransferEvent.NavigateToNext -> { + // TODO: Navigate to next screen + } + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_bank_transfer), + backPress = { + viewModel.trySendAction(BankTransferAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier.fillMaxWidth(), + ) { + Tab( + selected = selectedTabIndex == 0, + onClick = { selectedTabIndex = 0 }, + text = { + Text( + text = stringResource(Res.string.feature_send_money_bank_transfer_to_others), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + }, + ) + Tab( + selected = selectedTabIndex == 1, + onClick = { selectedTabIndex = 1 }, + text = { + Text( + text = stringResource(Res.string.feature_send_money_bank_transfer_to_self), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + }, + ) + } + + when (selectedTabIndex) { + 0 -> BankTransferToOthersContent( + state = state, + onAccountNumberChange = { accountNumber -> + viewModel.trySendAction(BankTransferAction.UpdateAccountNumber(accountNumber)) + }, + onIfscCodeChange = { ifscCode -> + viewModel.trySendAction(BankTransferAction.UpdateIfscCode(ifscCode)) + }, + onSearchIfscClick = { + viewModel.trySendAction(BankTransferAction.SearchIfsc) + }, + onContinueClick = { + viewModel.trySendAction(BankTransferAction.Continue) + }, + ) + 1 -> BankTransferToSelfContent() + } + } + } + } +} + +@Composable +private fun BankTransferToOthersContent( + state: BankTransferState, + onAccountNumberChange: (String) -> Unit, + onIfscCodeChange: (String) -> Unit, + onSearchIfscClick: () -> Unit, + onContinueClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .padding(KptTheme.spacing.lg) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_receivers_bank_details), + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + MifosTextField( + value = state.accountNumber, + onValueChange = onAccountNumberChange, + label = stringResource(Res.string.feature_send_money_account_number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = state.accountNumber.isNotEmpty() && !state.isAccountNumberValid, + errorText = if (state.accountNumber.isNotEmpty() && !state.isAccountNumberValid) { + stringResource(Res.string.feature_send_money_account_number_error) + } else { + null + }, + ) + + MifosTextField( + value = state.ifscCode, + onValueChange = onIfscCodeChange, + label = stringResource(Res.string.feature_send_money_ifsc_code), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + trailingIcon = { + TextButton( + onClick = onSearchIfscClick, + ) { + Text( + text = stringResource(Res.string.feature_send_money_search_ifsc), + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.primary, + ) + } + }, + ) + + MifosButton( + text = { Text(stringResource(Res.string.feature_send_money_continue)) }, + onClick = onContinueClick, + enabled = state.isFormValid, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(Res.string.feature_send_money_bank_details_note), + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = KptTheme.spacing.sm), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + RecentTransfersSection() + } +} + +@Composable +private fun RecentTransfersSection( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_recent_transfers), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + RecentTransferItem( + name = "John Doe", + onClick = { /* TODO: Handle click */ }, + modifier = Modifier.weight(1f), + ) + RecentTransferItem( + name = "Jane Smith", + onClick = { /* TODO: Handle click */ }, + modifier = Modifier.weight(1f), + ) + RecentTransferItem( + name = "Mike Johnson", + onClick = { /* TODO: Handle click */ }, + modifier = Modifier.weight(1f), + ) + RecentTransferItem( + name = "Sarah Wilson", + onClick = { /* TODO: Handle click */ }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun RecentTransferItem( + name: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { onClick() } + .clip(RoundedCornerShape(KptTheme.spacing.sm)), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = name.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + text = name, + style = KptTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun BankTransferToSelfContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Bank Transfer To Self - Coming Soon", + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurface, + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt new file mode 100644 index 000000000..c11db1703 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import kotlinx.coroutines.flow.update +import org.mifospay.core.ui.utils.BaseViewModel + +class BankTransferViewModel : BaseViewModel( + initialState = BankTransferState(), +) { + + override fun handleAction(action: BankTransferAction) { + when (action) { + is BankTransferAction.NavigateBack -> { + sendEvent(BankTransferEvent.NavigateBack) + } + is BankTransferAction.UpdateAccountNumber -> { + mutableStateFlow.update { it.copy(accountNumber = action.accountNumber) } + } + is BankTransferAction.UpdateIfscCode -> { + mutableStateFlow.update { it.copy(ifscCode = action.ifscCode) } + } + is BankTransferAction.SearchIfsc -> { + // TODO: Implement IFSC search functionality + sendEvent(BankTransferEvent.ShowIfscSearch) + } + is BankTransferAction.Continue -> { + if (state.isFormValid) { + sendEvent(BankTransferEvent.NavigateToNext) + } + } + } + } +} + +data class BankTransferState( + val isLoading: Boolean = false, + val accountNumber: String = "", + val ifscCode: String = "", +) { + val isAccountNumberValid: Boolean + get() = accountNumber.isNotEmpty() && accountNumber.all { it.isDigit() } + + val isIfscCodeValid: Boolean + get() = ifscCode.isNotEmpty() + + val isFormValid: Boolean + get() = isAccountNumberValid && isIfscCodeValid +} + +sealed interface BankTransferEvent { + data object NavigateBack : BankTransferEvent + data object ShowIfscSearch : BankTransferEvent + data object NavigateToNext : BankTransferEvent +} + +sealed interface BankTransferAction { + data object NavigateBack : BankTransferAction + data class UpdateAccountNumber(val accountNumber: String) : BankTransferAction + data class UpdateIfscCode(val ifscCode: String) : BankTransferAction + data object SearchIfsc : BankTransferAction + data object Continue : BankTransferAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index 8af69abde..c0f97c111 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -11,6 +11,7 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.send.money.BankTransferViewModel import org.mifospay.feature.send.money.PayeeDetailsViewModel import org.mifospay.feature.send.money.ScannerModule import org.mifospay.feature.send.money.SendMoneyOptionsViewModel @@ -21,4 +22,5 @@ val SendMoneyModule = module { viewModelOf(::SendMoneyViewModel) viewModelOf(::SendMoneyOptionsViewModel) viewModelOf(::PayeeDetailsViewModel) + viewModelOf(::BankTransferViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 00962212e..87c99ca74 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -16,6 +16,7 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.BankTransferScreen import org.mifospay.feature.send.money.PayeeDetailsScreen import org.mifospay.feature.send.money.PayeeDetailsState import org.mifospay.feature.send.money.SendMoneyOptionsScreen @@ -27,6 +28,7 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" +const val BANK_TRANSFER_ROUTE = "bank_transfer_route" const val PAYEE_DETAILS_ROUTE = "payee_details_route" const val PAYEE_DETAILS_ARG = "qrCodeData" @@ -40,6 +42,10 @@ fun NavController.navigateToSendMoneyOptionsScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_OPTIONS_ROUTE, navOptions) +fun NavController.navigateToBankTransferScreen( + navOptions: NavOptions? = null, +) = navigate(BANK_TRANSFER_ROUTE, navOptions) + fun NavController.navigateToPayeeDetailsScreen( qrCodeData: String, navOptions: NavOptions? = null, @@ -101,6 +107,18 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( } } +fun NavGraphBuilder.bankTransferScreen( + onBackClick: () -> Unit, +) { + composableWithSlideTransitions( + route = BANK_TRANSFER_ROUTE, + ) { + BankTransferScreen( + onBackClick = onBackClick, + ) + } +} + fun NavGraphBuilder.payeeDetailsScreen( onBackClick: () -> Unit, onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, From 20d8c3049533391a6684d8d6c341984c5ff54d43 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Wed, 20 Aug 2025 13:42:37 +0530 Subject: [PATCH 08/10] feat(feature:send-money): add self transfer screen --- cmp-android/prodRelease-badging.txt | 2 +- .../composeResources/values/strings.xml | 2 + .../feature/send/money/BankTransferScreen.kt | 347 +++++++++++++++++- .../send/money/BankTransferViewModel.kt | 91 ++++- 4 files changed, 433 insertions(+), 9 deletions(-) diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 522e01de8..f05c4c49a 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.10' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index d77836502..cc5a77050 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -60,4 +60,6 @@ Continue This information will be securely saved as per Mifos Initiative Terms of Service and Privacy Policy Recent Transfers + Self Transfer + Select different accounts for self transfer \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt index d073a2a81..df6667fdc 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -58,6 +60,8 @@ import mobile_wallet.feature.send_money.generated.resources.feature_send_money_i import mobile_wallet.feature.send_money.generated.resources.feature_send_money_receivers_bank_details import mobile_wallet.feature.send_money.generated.resources.feature_send_money_recent_transfers import mobile_wallet.feature.send_money.generated.resources.feature_send_money_search_ifsc +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_select_different_accounts +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_self_transfer import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.mifospay.core.designsystem.component.MifosButton @@ -65,6 +69,7 @@ import org.mifospay.core.designsystem.component.MifosGradientBackground import org.mifospay.core.designsystem.component.MifosScaffold import org.mifospay.core.designsystem.component.MifosTextField import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons import org.mifospay.core.ui.utils.EventsEffect import template.core.base.designsystem.theme.KptTheme @@ -89,6 +94,9 @@ fun BankTransferScreen( BankTransferEvent.NavigateToNext -> { // TODO: Navigate to next screen } + BankTransferEvent.AddBankAccount -> { + // TODO: Navigate to add bank account screen + } } } @@ -153,7 +161,7 @@ fun BankTransferScreen( viewModel.trySendAction(BankTransferAction.Continue) }, ) - 1 -> BankTransferToSelfContent() + 1 -> BankTransferToSelfContent(viewModel = viewModel) } } } @@ -334,16 +342,343 @@ private fun RecentTransferItem( @Composable private fun BankTransferToSelfContent( + viewModel: BankTransferViewModel, modifier: Modifier = Modifier, ) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .padding(KptTheme.spacing.lg) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), ) { Text( - text = "Bank Transfer To Self - Coming Soon", - style = KptTheme.typography.bodyLarge, + text = stringResource(Res.string.feature_send_money_self_transfer), + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, color = KptTheme.colorScheme.onSurface, ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + BankAccountSelectionSection( + viewModel = viewModel, + isFromAccount = true, + ) + + HorizontalDivider( + Modifier.padding(vertical = KptTheme.spacing.md), + thickness = 1.dp, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.3f), + ) + + BankAccountSelectionSection( + viewModel = viewModel, + isFromAccount = false, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + if (!state.isSelfTransferValid && (state.selectedFromBankAccount != null || state.selectedToBankAccount != null)) { + Text( + text = stringResource(Res.string.feature_send_money_select_different_accounts), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = KptTheme.spacing.sm), + ) + } + + MifosButton( + text = { Text(stringResource(Res.string.feature_send_money_continue)) }, + onClick = { + viewModel.trySendAction(BankTransferAction.ContinueSelfTransfer) + }, + enabled = state.isSelfTransferValid, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun BankAccountSelectionSection( + viewModel: BankTransferViewModel, + isFromAccount: Boolean, + modifier: Modifier = Modifier, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (isFromAccount) { + viewModel.trySendAction(BankTransferAction.ToggleFromBankAccountDropdown(!state.isFromBankAccountDropdownExpanded)) + } else { + viewModel.trySendAction(BankTransferAction.ToggleToBankAccountDropdown(!state.isToBankAccountDropdownExpanded)) + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isFromAccount) "Select bank account to transfer from" else "Select bank account to transfer to", + style = KptTheme.typography.titleSmall, + color = KptTheme.colorScheme.onSurface, + ) + + Icon( + imageVector = MifosIcons.KeyboardArrowDown, + contentDescription = "Expand", + tint = KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } + + if ((isFromAccount && state.isFromBankAccountDropdownExpanded) || (!isFromAccount && state.isToBankAccountDropdownExpanded)) { + BankAccountSelectionList( + bankAccounts = state.bankAccounts, + selectedBankAccount = if (isFromAccount) state.selectedFromBankAccount else state.selectedToBankAccount, + onBankAccountSelect = { bankAccount -> + if (isFromAccount) { + viewModel.trySendAction(BankTransferAction.SelectFromBankAccount(bankAccount)) + } else { + viewModel.trySendAction(BankTransferAction.SelectToBankAccount(bankAccount)) + } + }, + onAddBankAccount = { + viewModel.trySendAction(BankTransferAction.AddBankAccount) + }, + ) + } else { + BankAccountSelectionButton( + selectedBankAccount = if (isFromAccount) state.selectedFromBankAccount else state.selectedToBankAccount, + onClick = { + if (isFromAccount) { + viewModel.trySendAction(BankTransferAction.ToggleFromBankAccountDropdown(true)) + } else { + viewModel.trySendAction(BankTransferAction.ToggleToBankAccountDropdown(true)) + } + }, + ) + } + } +} + +@Composable +private fun BankAccountSelectionButton( + selectedBankAccount: BankAccount?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(KptTheme.spacing.sm), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = "Bank Logo", + tint = KptTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = selectedBankAccount?.bankName ?: "Select a bank account", + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + + if (selectedBankAccount != null) { + Text( + text = selectedBankAccount.maskedAccountNumber, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + } + + if (selectedBankAccount != null) { + Icon( + imageVector = MifosIcons.Check, + contentDescription = "Selected", + tint = KptTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun BankAccountSelectionList( + bankAccounts: List, + selectedBankAccount: BankAccount?, + onBankAccountSelect: (BankAccount) -> Unit, + onAddBankAccount: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + bankAccounts.forEach { bankAccount -> + BankAccountSelectionItem( + bankAccount = bankAccount, + isSelected = selectedBankAccount?.id == bankAccount.id, + onClick = { onBankAccountSelect(bankAccount) }, + ) + } + + BankAccountAddItem( + onClick = onAddBankAccount, + ) + } +} + +@Composable +private fun BankAccountSelectionItem( + bankAccount: BankAccount, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(KptTheme.spacing.sm), + color = if (isSelected) KptTheme.colorScheme.primaryContainer else KptTheme.colorScheme.surface, + tonalElevation = if (isSelected) 0.dp else 1.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = if (isSelected) KptTheme.colorScheme.primary else KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = "Bank Logo", + tint = if (isSelected) KptTheme.colorScheme.onPrimary else KptTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = bankAccount.bankName, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (isSelected) KptTheme.colorScheme.onPrimaryContainer else KptTheme.colorScheme.onSurface, + ) + + Text( + text = bankAccount.maskedAccountNumber, + style = KptTheme.typography.bodySmall, + color = if (isSelected) KptTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + + Text( + text = bankAccount.accountType, + style = KptTheme.typography.bodySmall, + color = if (isSelected) KptTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + + Icon( + imageVector = if (isSelected) MifosIcons.Check else MifosIcons.RadioButtonUnchecked, + contentDescription = if (isSelected) "Selected" else "Not selected", + tint = if (isSelected) KptTheme.colorScheme.primary else KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun BankAccountAddItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(KptTheme.spacing.sm), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = "Add Bank Account", + tint = KptTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + + Text( + text = "Add bank account", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.primary, + ) + } } } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt index c11db1703..e46e88a5b 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt @@ -13,7 +13,11 @@ import kotlinx.coroutines.flow.update import org.mifospay.core.ui.utils.BaseViewModel class BankTransferViewModel : BaseViewModel( - initialState = BankTransferState(), + initialState = BankTransferState( + selectedFromBankAccount = BankTransferState().bankAccounts.first { it.isDefault }, + selectedToBankAccount = BankTransferState().bankAccounts.first { it.isDefault }, + isFromBankAccountDropdownExpanded = true, + ), ) { override fun handleAction(action: BankTransferAction) { @@ -28,7 +32,6 @@ class BankTransferViewModel : BaseViewModel { - // TODO: Implement IFSC search functionality sendEvent(BankTransferEvent.ShowIfscSearch) } is BankTransferAction.Continue -> { @@ -36,14 +39,70 @@ class BankTransferViewModel : BaseViewModel { + if (state.isSelfTransferValid) { + sendEvent(BankTransferEvent.NavigateToNext) + } + } + is BankTransferAction.ToggleFromBankAccountDropdown -> { + mutableStateFlow.update { + it.copy( + isFromBankAccountDropdownExpanded = action.expanded, + isToBankAccountDropdownExpanded = false, + ) + } + } + is BankTransferAction.ToggleToBankAccountDropdown -> { + mutableStateFlow.update { + it.copy( + isToBankAccountDropdownExpanded = action.expanded, + isFromBankAccountDropdownExpanded = false, + ) + } + } + is BankTransferAction.SelectFromBankAccount -> { + mutableStateFlow.update { + it.copy( + selectedFromBankAccount = action.bankAccount, + isFromBankAccountDropdownExpanded = false, + isToBankAccountDropdownExpanded = true, + ) + } + } + is BankTransferAction.SelectToBankAccount -> { + mutableStateFlow.update { + it.copy( + selectedToBankAccount = action.bankAccount, + isToBankAccountDropdownExpanded = false, + ) + } + } + is BankTransferAction.AddBankAccount -> { + sendEvent(BankTransferEvent.AddBankAccount) + } } } } +data class BankAccount( + val id: String, + val bankName: String, + val accountNumber: String, + val accountType: String, + val isDefault: Boolean = false, +) { + val maskedAccountNumber: String + get() = "****${accountNumber.takeLast(4)}" +} + data class BankTransferState( val isLoading: Boolean = false, val accountNumber: String = "", val ifscCode: String = "", + val selectedFromBankAccount: BankAccount? = null, + val selectedToBankAccount: BankAccount? = null, + val isFromBankAccountDropdownExpanded: Boolean = false, + val isToBankAccountDropdownExpanded: Boolean = false, ) { val isAccountNumberValid: Boolean get() = accountNumber.isNotEmpty() && accountNumber.all { it.isDigit() } @@ -53,12 +112,34 @@ data class BankTransferState( val isFormValid: Boolean get() = isAccountNumberValid && isIfscCodeValid + + val isSelfTransferValid: Boolean + get() = selectedFromBankAccount != null && + selectedToBankAccount != null && + selectedFromBankAccount.id != selectedToBankAccount.id + + val bankAccounts: List = listOf( + BankAccount( + id = "1", + bankName = "State Bank of India", + accountNumber = "1234567890", + accountType = "Savings Account", + isDefault = true, + ), + BankAccount( + id = "2", + bankName = "HDFC Bank", + accountNumber = "0987654321", + accountType = "Current Account", + ), + ) } sealed interface BankTransferEvent { data object NavigateBack : BankTransferEvent data object ShowIfscSearch : BankTransferEvent data object NavigateToNext : BankTransferEvent + data object AddBankAccount : BankTransferEvent } sealed interface BankTransferAction { @@ -67,4 +148,10 @@ sealed interface BankTransferAction { data class UpdateIfscCode(val ifscCode: String) : BankTransferAction data object SearchIfsc : BankTransferAction data object Continue : BankTransferAction + data object ContinueSelfTransfer : BankTransferAction + data class ToggleFromBankAccountDropdown(val expanded: Boolean) : BankTransferAction + data class ToggleToBankAccountDropdown(val expanded: Boolean) : BankTransferAction + data class SelectFromBankAccount(val bankAccount: BankAccount) : BankTransferAction + data class SelectToBankAccount(val bankAccount: BankAccount) : BankTransferAction + data object AddBankAccount : BankTransferAction } From 5033ed2863b4883e787b9abe3c3bb8e575ab5a4b Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Sat, 23 Aug 2025 19:13:24 +0530 Subject: [PATCH 09/10] feat(feature:send-money): implement search ifsc screen --- cmp-android/prodRelease-badging.txt | 2 +- .../shared/navigation/MifosNavHost.kt | 13 + .../composeResources/values/strings.xml | 7 + .../feature/send/money/BankTransferScreen.kt | 6 +- .../send/money/BankTransferViewModel.kt | 6 + .../feature/send/money/SearchIfscScreen.kt | 422 ++++++++++++++++++ .../feature/send/money/SearchIfscViewModel.kt | 392 ++++++++++++++++ .../feature/send/money/di/SendMoneyModule.kt | 2 + .../send/money/navigation/SendNavigation.kt | 22 + 9 files changed, 870 insertions(+), 2 deletions(-) create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscScreen.kt create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscViewModel.kt diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index f05c4c49a..768bd4b66 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.10' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.11' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index 63c34a28e..52e87336a 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -76,9 +76,11 @@ import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE import org.mifospay.feature.send.money.navigation.bankTransferScreen import org.mifospay.feature.send.money.navigation.navigateToBankTransferScreen import org.mifospay.feature.send.money.navigation.navigateToPayeeDetailsScreen +import org.mifospay.feature.send.money.navigation.navigateToSearchIfscScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen import org.mifospay.feature.send.money.navigation.payeeDetailsScreen +import org.mifospay.feature.send.money.navigation.searchIfscScreen import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen @@ -325,6 +327,17 @@ internal fun MifosNavHost( bankTransferScreen( onBackClick = navController::popBackStack, + onSearchIfscClick = { + navController.navigateToSearchIfscScreen() + }, + ) + + searchIfscScreen( + onBackClick = navController::popBackStack, + onIfscSelected = { ifscCode -> + // The IFSC code will be handled by the BankTransferViewModel + // when the user returns to the Bank Transfer screen + }, ) payeeDetailsScreen( diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index cc5a77050..db07d3760 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -62,4 +62,11 @@ Recent Transfers Self Transfer Select different accounts for self transfer + Search IFSC code or bank name + Searching... + No IFSC codes found + Search for IFSC codes by bank name or code + Bank name + Bank branch + Cancel \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt index df6667fdc..b666e3c19 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt @@ -77,6 +77,7 @@ import template.core.base.designsystem.theme.KptTheme @Composable fun BankTransferScreen( onBackClick: () -> Unit, + onSearchIfscClick: () -> Unit, modifier: Modifier = Modifier, viewModel: BankTransferViewModel = koinViewModel(), ) { @@ -89,7 +90,7 @@ fun BankTransferScreen( onBackClick.invoke() } BankTransferEvent.ShowIfscSearch -> { - // TODO: Implement IFSC search dialog/screen + onSearchIfscClick.invoke() } BankTransferEvent.NavigateToNext -> { // TODO: Navigate to next screen @@ -97,6 +98,9 @@ fun BankTransferScreen( BankTransferEvent.AddBankAccount -> { // TODO: Navigate to add bank account screen } + is BankTransferEvent.IfscCodeSelected -> { + // IFSC code has been selected and updated in the state + } } } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt index e46e88a5b..034c4677f 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt @@ -80,6 +80,10 @@ class BankTransferViewModel : BaseViewModel { sendEvent(BankTransferEvent.AddBankAccount) } + is BankTransferAction.SelectIfscCode -> { + mutableStateFlow.update { it.copy(ifscCode = action.ifscCode.code) } + sendEvent(BankTransferEvent.IfscCodeSelected(action.ifscCode)) + } } } } @@ -140,6 +144,7 @@ sealed interface BankTransferEvent { data object ShowIfscSearch : BankTransferEvent data object NavigateToNext : BankTransferEvent data object AddBankAccount : BankTransferEvent + data class IfscCodeSelected(val ifscCode: IfscCode) : BankTransferEvent } sealed interface BankTransferAction { @@ -154,4 +159,5 @@ sealed interface BankTransferAction { data class SelectFromBankAccount(val bankAccount: BankAccount) : BankTransferAction data class SelectToBankAccount(val bankAccount: BankAccount) : BankTransferAction data object AddBankAccount : BankTransferAction + data class SelectIfscCode(val ifscCode: IfscCode) : BankTransferAction } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscScreen.kt new file mode 100644 index 000000000..df8d64435 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscScreen.kt @@ -0,0 +1,422 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_branch +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_name +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_cancel +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_continue +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_ifsc_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_search_ifsc +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTextField +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +// TODO replace dummy data with actual data or call API +// TODO fix bank name input box visibility +@Composable +fun SearchIfscScreen( + onBackClick: () -> Unit, + onIfscSelected: (IfscCode) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchIfscViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val selectedBank = state.selectedBank + val selectedBranch = state.selectedBranch + val filteredBranches = state.filteredBranches + val bankFocusRequester = remember { FocusRequester() } + val branchFocusRequester = remember { FocusRequester() } + + EventsEffect(viewModel) { event -> + when (event) { + SearchIfscEvent.NavigateBack -> { + onBackClick.invoke() + } + + is SearchIfscEvent.IfscSelected -> { + onIfscSelected(event.ifscCode) + onBackClick.invoke() + } + } + } + + LaunchedEffect(Unit) { + bankFocusRequester.requestFocus() + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_search_ifsc), + backPress = { + viewModel.trySendAction(SearchIfscAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + MifosTextField( + value = selectedBank?.name ?: state.bankName, + onValueChange = { bankName -> + if (selectedBank == null) { + viewModel.trySendAction(SearchIfscAction.UpdateBankName(bankName)) + } + }, + label = stringResource(Res.string.feature_send_money_bank_name), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier + .fillMaxWidth() + .focusRequester(bankFocusRequester) + .clickable { + if (selectedBank != null) { + viewModel.trySendAction(SearchIfscAction.ClearBankSelection) + } + }, + enabled = true, + readOnly = selectedBank != null, + leadingIcon = selectedBank?.let { + { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = "Bank Logo", + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClickClearIcon = { + if (selectedBank != null) { + viewModel.trySendAction(SearchIfscAction.ClearBankSelection) + } else { + viewModel.trySendAction(SearchIfscAction.UpdateBankName("")) + } + }, + ) + + if (selectedBank != null) { + MifosTextField( + value = selectedBranch?.name ?: state.bankBranch, + onValueChange = { bankBranch -> + if (selectedBranch == null) { + viewModel.trySendAction( + SearchIfscAction.UpdateBankBranch( + bankBranch, + ), + ) + } + }, + label = stringResource(Res.string.feature_send_money_bank_branch), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier + .fillMaxWidth() + .focusRequester(branchFocusRequester) + .clickable { + if (selectedBranch != null) { + viewModel.trySendAction(SearchIfscAction.ClearBranchSelection) + } + }, + enabled = true, + readOnly = selectedBranch != null, + onClickClearIcon = { + if (selectedBranch != null) { + viewModel.trySendAction(SearchIfscAction.ClearBranchSelection) + } else { + viewModel.trySendAction(SearchIfscAction.UpdateBankBranch("")) + } + }, + ) + + if (state.selectedIfscCode != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = KptTheme.spacing.sm), + ) { + Text( + text = stringResource(Res.string.feature_send_money_ifsc_code), + style = KptTheme.typography.labelMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = state.selectedIfscCode!!, + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = KptTheme.spacing.xs), + ) + } + } + } + + if (selectedBank != null && selectedBranch == null && filteredBranches.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(vertical = KptTheme.spacing.sm), + color = KptTheme.colorScheme.outline.copy(alpha = 0.2f), + ) + + BranchList( + branches = filteredBranches, + onBranchSelected = { branch -> + viewModel.trySendAction(SearchIfscAction.SelectBranch(branch)) + }, + ) + } + } + + if (selectedBank == null) { + HorizontalDivider( + modifier = Modifier.padding(vertical = KptTheme.spacing.sm), + color = KptTheme.colorScheme.outline.copy(alpha = 0.2f), + ) + + BankList( + banks = dummyBanks.filter { bank -> + bank.name.contains(state.bankName, ignoreCase = true) + }, + onBankSelected = { bank -> + viewModel.trySendAction(SearchIfscAction.SelectBank(bank)) + }, + ) + } + + if (state.selectedIfscCode != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg) + .padding(bottom = KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + MifosOutlinedButton( + text = { Text(stringResource(Res.string.feature_send_money_cancel)) }, + onClick = { + viewModel.trySendAction(SearchIfscAction.NavigateBack) + }, + modifier = Modifier.fillMaxWidth(), + ) + + MifosButton( + text = { Text(stringResource(Res.string.feature_send_money_continue)) }, + onClick = { + val ifscCode = IfscCode( + code = state.selectedIfscCode!!, + bankName = selectedBank?.name ?: "", + branch = selectedBranch?.name ?: "", + address = "", + city = selectedBranch?.state ?: "", + state = selectedBranch?.state ?: "", + ) + viewModel.trySendAction(SearchIfscAction.SelectIfscCode(ifscCode)) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} + +@Composable +private fun BankList( + banks: List, + onBankSelected: (DummyBank) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + items( + items = banks, + key = { it.name }, + ) { bank -> + BankListItem( + bank = bank, + onClick = { onBankSelected(bank) }, + ) + } + } +} + +@Composable +private fun BankListItem( + bank: DummyBank, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.small, + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = "Bank Logo", + tint = KptTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + + Text( + text = bank.name, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun BranchList( + branches: List, + onBranchSelected: (DummyBankBranch) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + items( + items = branches, + key = { "${it.name}_${it.state}" }, + ) { branch -> + BranchListItem( + branch = branch, + onClick = { onBranchSelected(branch) }, + ) + } + } +} + +@Composable +private fun BranchListItem( + branch: DummyBankBranch, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.small, + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + ) { + Text( + text = branch.name, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + + Text( + text = branch.state, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = KptTheme.spacing.xs), + ) + } + } +} + +private val dummyBanks = listOf( + DummyBank("State Bank of India"), + DummyBank("HDFC Bank"), + DummyBank("ICICI Bank"), + DummyBank("Punjab National Bank"), + DummyBank("Bank of Baroda"), + DummyBank("Canara Bank"), + DummyBank("Union Bank of India"), + DummyBank("Axis Bank"), + DummyBank("Kotak Mahindra Bank"), + DummyBank("Yes Bank"), +) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscViewModel.kt new file mode 100644 index 000000000..730b45fbb --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SearchIfscViewModel.kt @@ -0,0 +1,392 @@ +/* + * Copyright 2024 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-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import kotlinx.coroutines.flow.update +import org.mifospay.core.ui.utils.BaseViewModel + +// TODO replace dummy data with actual data or call API +class SearchIfscViewModel : BaseViewModel( + initialState = SearchIfscState(), +) { + + override fun handleAction(action: SearchIfscAction) { + when (action) { + is SearchIfscAction.NavigateBack -> { + sendEvent(SearchIfscEvent.NavigateBack) + } + is SearchIfscAction.UpdateBankName -> { + mutableStateFlow.update { it.copy(bankName = action.bankName) } + } + is SearchIfscAction.UpdateSearchQuery -> { + mutableStateFlow.update { it.copy(searchQuery = action.query) } + performSearch(action.query) + } + is SearchIfscAction.SelectIfscCode -> { + sendEvent(SearchIfscEvent.IfscSelected(action.ifscCode)) + } + is SearchIfscAction.SelectBank -> { + val selectedBank = if (action.bank.name.isEmpty()) null else action.bank + mutableStateFlow.update { + it.copy( + bankName = "", + selectedBank = selectedBank, + bankBranch = "", + selectedBranch = null, + filteredBranches = if (selectedBank != null) getBranchesForBank(selectedBank.name) else emptyList(), + ) + } + } + is SearchIfscAction.UpdateBankBranch -> { + mutableStateFlow.update { + it.copy( + bankBranch = action.bankBranch, + filteredBranches = getFilteredBranches(action.bankBranch), + ) + } + } + is SearchIfscAction.ClearBankSelection -> { + mutableStateFlow.update { + it.copy( + bankName = "", + selectedBank = null, + bankBranch = "", + selectedBranch = null, + selectedIfscCode = null, + filteredBranches = emptyList(), + ) + } + } + is SearchIfscAction.SelectBranch -> { + val ifscCode = generateIfscCodeForBranch(action.branch, mutableStateFlow.value.selectedBank?.name ?: "") + mutableStateFlow.update { + it.copy( + selectedBranch = action.branch, + selectedIfscCode = ifscCode, + ) + } + } + is SearchIfscAction.ClearBranchSelection -> { + mutableStateFlow.update { it.copy(selectedBranch = null, selectedIfscCode = null) } + } + } + } + + private fun performSearch(query: String) { + if (query.length < 3) { + mutableStateFlow.update { + it.copy(searchState = SearchIfscState.SearchState.Empty) + } + return + } + + mutableStateFlow.update { + it.copy(searchState = SearchIfscState.SearchState.Loading) + } + + // Simulate search delay and results + // In a real implementation, this would call an API + val mockResults = getMockIfscResults(query) + + mutableStateFlow.update { + it.copy( + searchState = if (mockResults.isEmpty()) { + SearchIfscState.SearchState.Empty + } else { + SearchIfscState.SearchState.Success(mockResults) + }, + ) + } + } + + private fun getMockIfscResults(query: String): List { + val allIfscCodes = listOf( + IfscCode( + code = "SBIN0001234", + bankName = "State Bank of India", + branch = "Mumbai Main Branch", + address = "Mumbai, Maharashtra", + city = "Mumbai", + state = "Maharashtra", + ), + IfscCode( + code = "HDFC0001234", + bankName = "HDFC Bank", + branch = "Delhi Main Branch", + address = "Delhi, Delhi", + city = "Delhi", + state = "Delhi", + ), + IfscCode( + code = "ICIC0001234", + bankName = "ICICI Bank", + branch = "Bangalore Main Branch", + address = "Bangalore, Karnataka", + city = "Bangalore", + state = "Karnataka", + ), + IfscCode( + code = "AXIS0001234", + bankName = "Axis Bank", + branch = "Chennai Main Branch", + address = "Chennai, Tamil Nadu", + city = "Chennai", + state = "Tamil Nadu", + ), + IfscCode( + code = "KOTAK0001234", + bankName = "Kotak Mahindra Bank", + branch = "Pune Main Branch", + address = "Pune, Maharashtra", + city = "Pune", + state = "Maharashtra", + ), + ) + + return allIfscCodes.filter { ifscCode -> + ifscCode.code.contains(query, ignoreCase = true) || + ifscCode.bankName.contains(query, ignoreCase = true) || + ifscCode.branch.contains(query, ignoreCase = true) || + ifscCode.city.contains(query, ignoreCase = true) + } + } + + private fun getFilteredBranches(query: String): List { + val currentState = mutableStateFlow.value + val selectedBank = currentState.selectedBank ?: return emptyList() + + if (query.isEmpty()) { + return getBranchesForBank(selectedBank.name) + } + + return getBranchesForBank(selectedBank.name).filter { branch -> + branch.name.contains(query, ignoreCase = true) || + branch.state.contains(query, ignoreCase = true) + } + } + + private fun getBranchesForBank(bankName: String): List { + return when (bankName) { + "State Bank of India" -> sbiBranches + "HDFC Bank" -> hdfcBranches + "ICICI Bank" -> iciciBranches + "Punjab National Bank" -> pnbBranches + "Bank of Baroda" -> bobBranches + "Canara Bank" -> canaraBranches + "Union Bank of India" -> unionBranches + "Axis Bank" -> axisBranches + "Kotak Mahindra Bank" -> kotakBranches + "Yes Bank" -> yesBranches + else -> emptyList() + } + } + + private fun generateIfscCodeForBranch(branch: DummyBankBranch, bankName: String): String { + val bankCode = when (bankName) { + "State Bank of India" -> "SBIN" + "HDFC Bank" -> "HDFC" + "ICICI Bank" -> "ICIC" + "Punjab National Bank" -> "PNBN" + "Bank of Baroda" -> "BARB" + "Canara Bank" -> "CANA" + "Union Bank of India" -> "UBIN" + "Axis Bank" -> "AXIS" + "Kotak Mahindra Bank" -> "KOTAK" + "Yes Bank" -> "YESB" + else -> "XXXX" + } + + val branchCode = branch.name.replace(" ", "").take(4).uppercase() + val cityCode = branch.state.take(2).uppercase() + val sequenceNumber = "0001" + + return "$bankCode$branchCode$cityCode$sequenceNumber" + } +} + +private val sbiBranches = listOf( + DummyBankBranch("Mumbai Main Branch", "Maharashtra"), + DummyBankBranch("Delhi Main Branch", "Delhi"), + DummyBankBranch("Bangalore Main Branch", "Karnataka"), + DummyBankBranch("Chennai Main Branch", "Tamil Nadu"), + DummyBankBranch("Kolkata Main Branch", "West Bengal"), + DummyBankBranch("Hyderabad Main Branch", "Telangana"), + DummyBankBranch("Ahmedabad Main Branch", "Gujarat"), + DummyBankBranch("Pune Main Branch", "Maharashtra"), + DummyBankBranch("Jaipur Main Branch", "Rajasthan"), + DummyBankBranch("Lucknow Main Branch", "Uttar Pradesh"), + DummyBankBranch("Chandigarh Main Branch", "Chandigarh"), + DummyBankBranch("Indore Main Branch", "Madhya Pradesh"), + DummyBankBranch("Bhopal Main Branch", "Madhya Pradesh"), + DummyBankBranch("Patna Main Branch", "Bihar"), + DummyBankBranch("Bhubaneswar Main Branch", "Odisha"), +) + +private val hdfcBranches = listOf( + DummyBankBranch("Andheri West Branch", "Maharashtra"), + DummyBankBranch("Bandra Kurla Complex", "Maharashtra"), + DummyBankBranch("Connaught Place", "Delhi"), + DummyBankBranch("Koramangala", "Karnataka"), + DummyBankBranch("T Nagar", "Tamil Nadu"), + DummyBankBranch("Koregaon Park", "Maharashtra"), + DummyBankBranch("Banjara Hills", "Telangana"), + DummyBankBranch("Salt Lake City", "West Bengal"), + DummyBankBranch("Satellite", "Gujarat"), + DummyBankBranch("Malviya Nagar", "Rajasthan"), + DummyBankBranch("Gomti Nagar", "Uttar Pradesh"), + DummyBankBranch("Sector 17", "Chandigarh"), + DummyBankBranch("Vijay Nagar", "Madhya Pradesh"), + DummyBankBranch("Arera Colony", "Madhya Pradesh"), + DummyBankBranch("Boring Road", "Bihar"), + DummyBankBranch("Nayapalli", "Odisha"), +) + +private val iciciBranches = listOf( + DummyBankBranch("Marine Drive", "Maharashtra"), + DummyBankBranch("Lajpat Nagar", "Delhi"), + DummyBankBranch("Indiranagar", "Karnataka"), + DummyBankBranch("Anna Nagar", "Tamil Nadu"), + DummyBankBranch("Kalyani Nagar", "Maharashtra"), + DummyBankBranch("Jubilee Hills", "Telangana"), + DummyBankBranch("Park Street", "West Bengal"), + DummyBankBranch("Vastrapur", "Gujarat"), + DummyBankBranch("C Scheme", "Rajasthan"), + DummyBankBranch("Hazratganj", "Uttar Pradesh"), +) + +private val pnbBranches = listOf( + DummyBankBranch("Connaught Place", "Delhi"), + DummyBankBranch("Mumbai Central", "Maharashtra"), + DummyBankBranch("Koramangala", "Karnataka"), + DummyBankBranch("T Nagar", "Tamil Nadu"), + DummyBankBranch("Salt Lake City", "West Bengal"), + DummyBankBranch("Satellite", "Gujarat"), + DummyBankBranch("Malviya Nagar", "Rajasthan"), + DummyBankBranch("Gomti Nagar", "Uttar Pradesh"), +) + +private val bobBranches = listOf( + DummyBankBranch("Mumbai Main Branch", "Maharashtra"), + DummyBankBranch("Delhi Main Branch", "Delhi"), + DummyBankBranch("Bangalore Main Branch", "Karnataka"), + DummyBankBranch("Chennai Main Branch", "Tamil Nadu"), + DummyBankBranch("Kolkata Main Branch", "West Bengal"), + DummyBankBranch("Ahmedabad Main Branch", "Gujarat"), + DummyBankBranch("Pune Main Branch", "Maharashtra"), + DummyBankBranch("Jaipur Main Branch", "Rajasthan"), +) + +private val canaraBranches = listOf( + DummyBankBranch("Mumbai Main Branch", "Maharashtra"), + DummyBankBranch("Delhi Main Branch", "Delhi"), + DummyBankBranch("Bangalore Main Branch", "Karnataka"), + DummyBankBranch("Chennai Main Branch", "Tamil Nadu"), + DummyBankBranch("Kolkata Main Branch", "West Bengal"), + DummyBankBranch("Hyderabad Main Branch", "Telangana"), + DummyBankBranch("Ahmedabad Main Branch", "Gujarat"), + DummyBankBranch("Pune Main Branch", "Maharashtra"), +) + +private val unionBranches = listOf( + DummyBankBranch("Mumbai Main Branch", "Maharashtra"), + DummyBankBranch("Delhi Main Branch", "Delhi"), + DummyBankBranch("Bangalore Main Branch", "Karnataka"), + DummyBankBranch("Chennai Main Branch", "Tamil Nadu"), + DummyBankBranch("Kolkata Main Branch", "West Bengal"), + DummyBankBranch("Hyderabad Main Branch", "Telangana"), + DummyBankBranch("Ahmedabad Main Branch", "Gujarat"), + DummyBankBranch("Pune Main Branch", "Maharashtra"), +) + +private val axisBranches = listOf( + DummyBankBranch("Andheri West Branch", "Maharashtra"), + DummyBankBranch("Bandra Kurla Complex", "Maharashtra"), + DummyBankBranch("Connaught Place", "Delhi"), + DummyBankBranch("Koramangala", "Karnataka"), + DummyBankBranch("T Nagar", "Tamil Nadu"), + DummyBankBranch("Banjara Hills", "Telangana"), + DummyBankBranch("Salt Lake City", "West Bengal"), + DummyBankBranch("Satellite", "Gujarat"), +) + +private val kotakBranches = listOf( + DummyBankBranch("Andheri West Branch", "Maharashtra"), + DummyBankBranch("Bandra Kurla Complex", "Maharashtra"), + DummyBankBranch("Connaught Place", "Delhi"), + DummyBankBranch("Koramangala", "Karnataka"), + DummyBankBranch("T Nagar", "Tamil Nadu"), + DummyBankBranch("Banjara Hills", "Telangana"), + DummyBankBranch("Salt Lake City", "West Bengal"), + DummyBankBranch("Satellite", "Gujarat"), +) + +private val yesBranches = listOf( + DummyBankBranch("Andheri West Branch", "Maharashtra"), + DummyBankBranch("Bandra Kurla Complex", "Maharashtra"), + DummyBankBranch("Connaught Place", "Delhi"), + DummyBankBranch("Koramangala", "Karnataka"), + DummyBankBranch("T Nagar", "Tamil Nadu"), + DummyBankBranch("Banjara Hills", "Telangana"), + DummyBankBranch("Salt Lake City", "West Bengal"), + DummyBankBranch("Satellite", "Gujarat"), +) + +data class IfscCode( + val code: String, + val bankName: String, + val branch: String, + val address: String, + val city: String, + val state: String, +) + +data class DummyBank( + val name: String, +) + +data class DummyBankBranch( + val name: String, + val state: String, +) + +data class SearchIfscState( + val bankName: String = "", + val searchQuery: String = "", + val searchState: SearchState = SearchState.Empty, + val selectedBank: DummyBank? = null, + val bankBranch: String = "", + val selectedBranch: DummyBankBranch? = null, + val filteredBranches: List = emptyList(), + val selectedIfscCode: String? = null, +) { + sealed interface SearchState { + data object Loading : SearchState + data object Empty : SearchState + data class Success(val results: List) : SearchState + data class Error(val message: String) : SearchState + } +} + +sealed interface SearchIfscEvent { + data object NavigateBack : SearchIfscEvent + data class IfscSelected(val ifscCode: IfscCode) : SearchIfscEvent +} + +sealed interface SearchIfscAction { + data object NavigateBack : SearchIfscAction + data class UpdateBankName(val bankName: String) : SearchIfscAction + data class UpdateSearchQuery(val query: String) : SearchIfscAction + data class SelectIfscCode(val ifscCode: IfscCode) : SearchIfscAction + data class SelectBank(val bank: DummyBank) : SearchIfscAction + data class UpdateBankBranch(val bankBranch: String) : SearchIfscAction + data object ClearBankSelection : SearchIfscAction + data class SelectBranch(val branch: DummyBankBranch) : SearchIfscAction + data object ClearBranchSelection : SearchIfscAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index c0f97c111..2a2fb2aa4 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -14,6 +14,7 @@ import org.koin.dsl.module import org.mifospay.feature.send.money.BankTransferViewModel import org.mifospay.feature.send.money.PayeeDetailsViewModel import org.mifospay.feature.send.money.ScannerModule +import org.mifospay.feature.send.money.SearchIfscViewModel import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel @@ -23,4 +24,5 @@ val SendMoneyModule = module { viewModelOf(::SendMoneyOptionsViewModel) viewModelOf(::PayeeDetailsViewModel) viewModelOf(::BankTransferViewModel) + viewModelOf(::SearchIfscViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 87c99ca74..045fe59cb 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -19,6 +19,7 @@ import org.mifospay.core.ui.composableWithSlideTransitions import org.mifospay.feature.send.money.BankTransferScreen import org.mifospay.feature.send.money.PayeeDetailsScreen import org.mifospay.feature.send.money.PayeeDetailsState +import org.mifospay.feature.send.money.SearchIfscScreen import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen @@ -29,6 +30,7 @@ const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONE const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" const val BANK_TRANSFER_ROUTE = "bank_transfer_route" +const val SEARCH_IFSC_ROUTE = "search_ifsc_route" const val PAYEE_DETAILS_ROUTE = "payee_details_route" const val PAYEE_DETAILS_ARG = "qrCodeData" @@ -46,6 +48,10 @@ fun NavController.navigateToBankTransferScreen( navOptions: NavOptions? = null, ) = navigate(BANK_TRANSFER_ROUTE, navOptions) +fun NavController.navigateToSearchIfscScreen( + navOptions: NavOptions? = null, +) = navigate(SEARCH_IFSC_ROUTE, navOptions) + fun NavController.navigateToPayeeDetailsScreen( qrCodeData: String, navOptions: NavOptions? = null, @@ -109,12 +115,28 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( fun NavGraphBuilder.bankTransferScreen( onBackClick: () -> Unit, + onSearchIfscClick: () -> Unit, ) { composableWithSlideTransitions( route = BANK_TRANSFER_ROUTE, ) { BankTransferScreen( onBackClick = onBackClick, + onSearchIfscClick = onSearchIfscClick, + ) + } +} + +fun NavGraphBuilder.searchIfscScreen( + onBackClick: () -> Unit, + onIfscSelected: (org.mifospay.feature.send.money.IfscCode) -> Unit, +) { + composableWithSlideTransitions( + route = SEARCH_IFSC_ROUTE, + ) { + SearchIfscScreen( + onBackClick = onBackClick, + onIfscSelected = onIfscSelected, ) } } From 9daf161eb92b08e3ff84837f34908ec60b5f0b7c Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Wed, 27 Aug 2025 11:11:07 +0530 Subject: [PATCH 10/10] feat(feature:send-money): add fields in bank transfer screen and enhance UI/UX --- .../composeResources/values/strings.xml | 9 +- .../feature/send/money/BankTransferScreen.kt | 204 ++++++++++++++++-- .../send/money/BankTransferViewModel.kt | 99 ++++++++- .../send/money/navigation/SendNavigation.kt | 3 +- 4 files changed, 291 insertions(+), 24 deletions(-) diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index db07d3760..df0c9c394 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -51,7 +51,7 @@ Merchants More - + Receiver's Bank Details Bank account number Input not valid @@ -69,4 +69,11 @@ Bank name Bank branch Cancel + + Re-enter Bank Account Number + Receiver's Name + Account numbers do not match + Receiver's name is required + IFSC should be 4 letters, followed by 7 letters or digits + Confirm \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt index b666e3c19..eb8fd1e18 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -42,7 +43,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -51,14 +56,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import mobile_wallet.feature.send_money.generated.resources.Res import mobile_wallet.feature.send_money.generated.resources.feature_send_money_account_number import mobile_wallet.feature.send_money.generated.resources.feature_send_money_account_number_error +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_account_number_mismatch import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_details_note import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer_to_others import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer_to_self +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_confirm import mobile_wallet.feature.send_money.generated.resources.feature_send_money_continue import mobile_wallet.feature.send_money.generated.resources.feature_send_money_ifsc_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_ifsc_validation_error import mobile_wallet.feature.send_money.generated.resources.feature_send_money_receivers_bank_details +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_receivers_name +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_receivers_name_required import mobile_wallet.feature.send_money.generated.resources.feature_send_money_recent_transfers +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_reenter_account_number import mobile_wallet.feature.send_money.generated.resources.feature_send_money_search_ifsc import mobile_wallet.feature.send_money.generated.resources.feature_send_money_select_different_accounts import mobile_wallet.feature.send_money.generated.resources.feature_send_money_self_transfer @@ -164,6 +175,33 @@ fun BankTransferScreen( onContinueClick = { viewModel.trySendAction(BankTransferAction.Continue) }, + onReenterAccountNumberChange = { accountNumber -> + viewModel.trySendAction(BankTransferAction.UpdateReenterAccountNumber(accountNumber)) + }, + onReceiversNameChange = { name -> + viewModel.trySendAction(BankTransferAction.UpdateReceiversName(name)) + }, + onProceedWithTransferClick = { + viewModel.trySendAction(BankTransferAction.ProceedWithTransfer) + }, + onToggleAccountNumberMask = { + viewModel.trySendAction(BankTransferAction.ToggleAccountNumberMask) + }, + onToggleReenterAccountNumberMask = { + viewModel.trySendAction(BankTransferAction.ToggleReenterAccountNumberMask) + }, + onSetIfscFocus = { focused -> + viewModel.trySendAction(BankTransferAction.SetIfscFocus(focused)) + }, + onSetAccountNumberFocus = { focused -> + viewModel.trySendAction(BankTransferAction.SetAccountNumberFocus(focused)) + }, + onSetReenterAccountNumberFocus = { focused -> + viewModel.trySendAction(BankTransferAction.SetReenterAccountNumberFocus(focused)) + }, + onSetReceiversNameFocus = { focused -> + viewModel.trySendAction(BankTransferAction.SetReceiversNameFocus(focused)) + }, ) 1 -> BankTransferToSelfContent(viewModel = viewModel) } @@ -179,9 +217,20 @@ private fun BankTransferToOthersContent( onIfscCodeChange: (String) -> Unit, onSearchIfscClick: () -> Unit, onContinueClick: () -> Unit, + onReenterAccountNumberChange: (String) -> Unit, + onReceiversNameChange: (String) -> Unit, + onProceedWithTransferClick: () -> Unit, + onToggleAccountNumberMask: () -> Unit, + onToggleReenterAccountNumberMask: () -> Unit, + onSetIfscFocus: (Boolean) -> Unit, + onSetAccountNumberFocus: (Boolean) -> Unit, + onSetReenterAccountNumberFocus: (Boolean) -> Unit, + onSetReceiversNameFocus: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() + val accountNumberFocusRequester = remember { FocusRequester() } + val reenterAccountNumberFocusRequester = remember { FocusRequester() } Column( modifier = modifier @@ -197,24 +246,60 @@ private fun BankTransferToOthersContent( color = KptTheme.colorScheme.onSurface, ) - MifosTextField( - value = state.accountNumber, - onValueChange = onAccountNumberChange, - label = stringResource(Res.string.feature_send_money_account_number), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - isError = state.accountNumber.isNotEmpty() && !state.isAccountNumberValid, - errorText = if (state.accountNumber.isNotEmpty() && !state.isAccountNumberValid) { - stringResource(Res.string.feature_send_money_account_number_error) - } else { - null - }, - ) + Box( + modifier = Modifier.fillMaxWidth(), + ) { + MifosTextField( + value = state.getDisplayAccountNumber(state.accountNumber, state.isAccountNumberMasked), + onValueChange = { newValue -> + if (!state.isAccountNumberMasked) { + onAccountNumberChange(newValue) + } + }, + label = stringResource(Res.string.feature_send_money_account_number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + isError = state.accountNumber.isNotEmpty() && !state.isAccountNumberValid, + errorText = if (state.accountNumber.isNotEmpty() && !state.isAccountNumberValid) { + stringResource(Res.string.feature_send_money_account_number_error) + } else { + null + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(accountNumberFocusRequester) + .onFocusChanged { focusState -> + onSetAccountNumberFocus(focusState.isFocused) + }, + ) + + if (state.accountNumber.isNotEmpty() && state.isAccountNumberMasked) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { + onToggleAccountNumberMask() + // Focus the field after unmasking + accountNumberFocusRequester.requestFocus() + }, + ) + } + } MifosTextField( value = state.ifscCode, onValueChange = onIfscCodeChange, label = stringResource(Res.string.feature_send_money_ifsc_code), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Characters, + ), + isError = state.isIfscFocused && state.ifscCode.isNotEmpty() && !state.isIfscCodeValid, + errorText = if (state.isIfscFocused && state.ifscCode.isNotEmpty() && !state.isIfscCodeValid) { + stringResource(Res.string.feature_send_money_ifsc_validation_error) + } else { + null + }, trailingIcon = { TextButton( onClick = onSearchIfscClick, @@ -226,14 +311,95 @@ private fun BankTransferToOthersContent( ) } }, + modifier = Modifier.onFocusChanged { focusState -> + onSetIfscFocus(focusState.isFocused) + }, ) - MifosButton( - text = { Text(stringResource(Res.string.feature_send_money_continue)) }, - onClick = onContinueClick, - enabled = state.isFormValid, - modifier = Modifier.fillMaxWidth(), - ) + if (!state.showAdditionalFields) { + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = KptTheme.colorScheme.primary, + ) + } + } else { + MifosButton( + text = { Text(stringResource(Res.string.feature_send_money_continue)) }, + onClick = onContinueClick, + enabled = state.isFormValid, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + MifosTextField( + value = state.getDisplayAccountNumber(state.reenterAccountNumber, state.isReenterAccountNumberMasked), + onValueChange = { newValue -> + if (!state.isReenterAccountNumberMasked) { + onReenterAccountNumberChange(newValue) + } + }, + label = stringResource(Res.string.feature_send_money_reenter_account_number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + isError = state.reenterAccountNumber.isNotEmpty() && !state.isAccountNumberMatching, + errorText = if (state.reenterAccountNumber.isNotEmpty() && !state.isAccountNumberMatching) { + stringResource(Res.string.feature_send_money_account_number_mismatch) + } else { + null + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(reenterAccountNumberFocusRequester) + .onFocusChanged { focusState -> + onSetReenterAccountNumberFocus(focusState.isFocused) + }, + ) + + if (state.reenterAccountNumber.isNotEmpty() && state.isReenterAccountNumberMasked) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { + onToggleReenterAccountNumberMask() + // Focus the field after unmasking + reenterAccountNumberFocusRequester.requestFocus() + }, + ) + } + } + + MifosTextField( + value = state.receiversName, + onValueChange = onReceiversNameChange, + label = stringResource(Res.string.feature_send_money_receivers_name), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + isError = state.receiversName.isNotEmpty() && !state.isReceiversNameValid, + errorText = if (state.receiversName.isNotEmpty() && !state.isReceiversNameValid) { + stringResource(Res.string.feature_send_money_receivers_name_required) + } else { + null + }, + modifier = Modifier.onFocusChanged { focusState -> + onSetReceiversNameFocus(focusState.isFocused) + }, + ) + + MifosButton( + text = { Text(stringResource(Res.string.feature_send_money_confirm)) }, + onClick = onProceedWithTransferClick, + enabled = state.isAdditionalFieldsValid, + modifier = Modifier.fillMaxWidth(), + ) + } Text( text = stringResource(Res.string.feature_send_money_bank_details_note), diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt index 034c4677f..2ecc42d42 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt @@ -9,7 +9,10 @@ */ package org.mifospay.feature.send.money +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.mifospay.core.ui.utils.BaseViewModel class BankTransferViewModel : BaseViewModel( @@ -36,7 +39,12 @@ class BankTransferViewModel : BaseViewModel { if (state.isFormValid) { - sendEvent(BankTransferEvent.NavigateToNext) + mutableStateFlow.update { it.copy(isLoading = true) } + // Simulate network call with delay + viewModelScope.launch { + delay(1500) + mutableStateFlow.update { it.copy(isLoading = false, showAdditionalFields = true) } + } } } is BankTransferAction.ContinueSelfTransfer -> { @@ -84,6 +92,45 @@ class BankTransferViewModel : BaseViewModel { + mutableStateFlow.update { it.copy(reenterAccountNumber = action.accountNumber) } + } + is BankTransferAction.UpdateReceiversName -> { + mutableStateFlow.update { it.copy(receiversName = action.name) } + } + is BankTransferAction.ProceedWithTransfer -> { + if (state.isAdditionalFieldsValid) { + sendEvent(BankTransferEvent.NavigateToNext) + } + } + is BankTransferAction.ToggleAccountNumberMask -> { + mutableStateFlow.update { it.copy(isAccountNumberMasked = !it.isAccountNumberMasked) } + if (!state.isAccountNumberMasked) { + // When unmasking, we need to focus the field + // This will be handled by the UI layer + } + } + is BankTransferAction.ToggleReenterAccountNumberMask -> { + mutableStateFlow.update { it.copy(isReenterAccountNumberMasked = !it.isReenterAccountNumberMasked) } + } + is BankTransferAction.SetIfscFocus -> { + mutableStateFlow.update { it.copy(isIfscFocused = action.focused) } + if (action.focused && state.accountNumber.isNotEmpty()) { + mutableStateFlow.update { it.copy(isAccountNumberMasked = true) } + } + } + is BankTransferAction.SetAccountNumberFocus -> { + mutableStateFlow.update { it.copy(isAccountNumberFocused = action.focused) } + } + is BankTransferAction.SetReenterAccountNumberFocus -> { + mutableStateFlow.update { it.copy(isReenterAccountNumberFocused = action.focused) } + } + is BankTransferAction.SetReceiversNameFocus -> { + mutableStateFlow.update { it.copy(isReceiversNameFocused = action.focused) } + if (action.focused && state.reenterAccountNumber.isNotEmpty()) { + mutableStateFlow.update { it.copy(isReenterAccountNumberMasked = true) } + } + } } } } @@ -103,20 +150,41 @@ data class BankTransferState( val isLoading: Boolean = false, val accountNumber: String = "", val ifscCode: String = "", + val reenterAccountNumber: String = "", + val receiversName: String = "", + val showAdditionalFields: Boolean = false, + val isAccountNumberMasked: Boolean = false, + val isReenterAccountNumberMasked: Boolean = false, + val isIfscFocused: Boolean = false, + val isAccountNumberFocused: Boolean = false, + val isReenterAccountNumberFocused: Boolean = false, + val isReceiversNameFocused: Boolean = false, val selectedFromBankAccount: BankAccount? = null, val selectedToBankAccount: BankAccount? = null, val isFromBankAccountDropdownExpanded: Boolean = false, val isToBankAccountDropdownExpanded: Boolean = false, ) { val isAccountNumberValid: Boolean - get() = accountNumber.isNotEmpty() && accountNumber.all { it.isDigit() } + get() = accountNumber.isNotEmpty() && accountNumber.all { it.isLetterOrDigit() } val isIfscCodeValid: Boolean - get() = ifscCode.isNotEmpty() + get() = ifscCode.isNotEmpty() && ifscCode.matches(Regex("^[A-Z]{4}[A-Z0-9]{7}$")) + + val isReenterAccountNumberValid: Boolean + get() = reenterAccountNumber.isNotEmpty() && reenterAccountNumber.all { it.isLetterOrDigit() } + + val isReceiversNameValid: Boolean + get() = receiversName.isNotEmpty() && receiversName.trim().length >= 2 + + val isAccountNumberMatching: Boolean + get() = accountNumber == reenterAccountNumber val isFormValid: Boolean get() = isAccountNumberValid && isIfscCodeValid + val isAdditionalFieldsValid: Boolean + get() = isReenterAccountNumberValid && isReceiversNameValid && isAccountNumberMatching + val isSelfTransferValid: Boolean get() = selectedFromBankAccount != null && selectedToBankAccount != null && @@ -137,6 +205,22 @@ data class BankTransferState( accountType = "Current Account", ), ) + + fun getMaskedAccountNumber(accountNumber: String): String { + return if (accountNumber.isNotEmpty()) { + "*".repeat(accountNumber.length) + } else { + accountNumber + } + } + + fun getDisplayAccountNumber(accountNumber: String, isMasked: Boolean): String { + return if (isMasked && accountNumber.isNotEmpty()) { + getMaskedAccountNumber(accountNumber) + } else { + accountNumber + } + } } sealed interface BankTransferEvent { @@ -160,4 +244,13 @@ sealed interface BankTransferAction { data class SelectToBankAccount(val bankAccount: BankAccount) : BankTransferAction data object AddBankAccount : BankTransferAction data class SelectIfscCode(val ifscCode: IfscCode) : BankTransferAction + data class UpdateReenterAccountNumber(val accountNumber: String) : BankTransferAction + data class UpdateReceiversName(val name: String) : BankTransferAction + data object ProceedWithTransfer : BankTransferAction + data object ToggleAccountNumberMask : BankTransferAction + data object ToggleReenterAccountNumberMask : BankTransferAction + data class SetIfscFocus(val focused: Boolean) : BankTransferAction + data class SetAccountNumberFocus(val focused: Boolean) : BankTransferAction + data class SetReenterAccountNumberFocus(val focused: Boolean) : BankTransferAction + data class SetReceiversNameFocus(val focused: Boolean) : BankTransferAction } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 045fe59cb..261aa0181 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -17,6 +17,7 @@ import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions import org.mifospay.feature.send.money.BankTransferScreen +import org.mifospay.feature.send.money.IfscCode import org.mifospay.feature.send.money.PayeeDetailsScreen import org.mifospay.feature.send.money.PayeeDetailsState import org.mifospay.feature.send.money.SearchIfscScreen @@ -129,7 +130,7 @@ fun NavGraphBuilder.bankTransferScreen( fun NavGraphBuilder.searchIfscScreen( onBackClick: () -> Unit, - onIfscSelected: (org.mifospay.feature.send.money.IfscCode) -> Unit, + onIfscSelected: (IfscCode) -> Unit, ) { composableWithSlideTransitions( route = SEARCH_IFSC_ROUTE,