diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 91ced69b5..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.2-beta.0.3' 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 d6a94277b..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 @@ -72,7 +72,16 @@ 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.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 import org.mifospay.feature.standing.instruction.StandingInstructionsScreen @@ -97,6 +106,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -160,7 +170,7 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -279,12 +289,67 @@ 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 = { + navController.navigateToBankTransferScreen() + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onQrCodeScanned = { qrData -> + navController.navigateToSendMoneyScreen( + requestData = qrData, + navOptions = navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { + inclusive = true + } + }, + ) + }, + onNavigateToPayeeDetails = { qrCodeData -> + navController.navigateToPayeeDetailsScreen(qrCodeData) + }, + ) + sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, + navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen, navigateToScanQrScreen = navController::navigateToScanQr, ) + 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( + onBackClick = navController::popBackStack, + onNavigateToUpiPayment = { state -> + // TODO: Handle UPI payment navigation + }, + onNavigateToFineractPayment = { state -> + // TODO: Handle Fineract payment navigation + }, + ) + transferScreen( navigateBack = navController::popBackStack, onTransferSuccess = { @@ -322,6 +387,16 @@ internal fun MifosNavHost( }, ) }, + navigateToPayeeDetailsScreen = { + navController.navigateToPayeeDetailsScreen( + qrCodeData = it, + navOptions = navOptions { + popUpTo(SCAN_QR_ROUTE) { + inclusive = true + } + }, + ) + }, ) merchantTransferScreen( 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..545f7b574 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -0,0 +1,102 @@ +/* + * 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"] ?: run { + 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/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..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 @@ -22,6 +23,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 +131,8 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + + val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward + + val CurrencyRupee = Icons.Filled.CurrencyRupee } 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/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/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 cea7f82b7..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 @@ -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,10 +23,24 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - UpiQrCodeProcessor.decodeUpiString(data) + val isUpiQr = try { + UpiQrCodeProcessor.decodeUpiString(data) + true + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(data) + true + } else { + false + } + } _eventFlow.update { - ScanQrEvent.OnNavigateToSendScreen(data) + if (isUpiQr) { + ScanQrEvent.OnNavigateToPayeeDetails(data) + } else { + ScanQrEvent.OnNavigateToSendScreen(data) + } } true @@ -40,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/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64d..df0c9c394 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,42 @@ 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 + Choose how you want to send money + 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 + 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 + + 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 new file mode 100644 index 000000000..eb8fd1e18 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferScreen.kt @@ -0,0 +1,854 @@ +/* + * 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.CircularProgressIndicator +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 +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.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 +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_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 +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.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BankTransferScreen( + onBackClick: () -> Unit, + onSearchIfscClick: () -> 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 -> { + onSearchIfscClick.invoke() + } + BankTransferEvent.NavigateToNext -> { + // TODO: Navigate to next screen + } + BankTransferEvent.AddBankAccount -> { + // TODO: Navigate to add bank account screen + } + is BankTransferEvent.IfscCodeSelected -> { + // IFSC code has been selected and updated in the state + } + } + } + + 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) + }, + 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) + } + } + } + } +} + +@Composable +private fun BankTransferToOthersContent( + state: BankTransferState, + onAccountNumberChange: (String) -> Unit, + 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 + .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, + ) + + 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, + 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, + ) { + Text( + text = stringResource(Res.string.feature_send_money_search_ifsc), + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.primary, + ) + } + }, + modifier = Modifier.onFocusChanged { focusState -> + onSetIfscFocus(focusState.isFocused) + }, + ) + + 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), + 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( + viewModel: BankTransferViewModel, + modifier: Modifier = Modifier, +) { + 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 = 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 new file mode 100644 index 000000000..2ecc42d42 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/BankTransferViewModel.kt @@ -0,0 +1,256 @@ +/* + * 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.delay +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.mifospay.core.ui.utils.BaseViewModel + +class BankTransferViewModel : BaseViewModel( + initialState = BankTransferState( + selectedFromBankAccount = BankTransferState().bankAccounts.first { it.isDefault }, + selectedToBankAccount = BankTransferState().bankAccounts.first { it.isDefault }, + isFromBankAccountDropdownExpanded = true, + ), +) { + + 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 -> { + sendEvent(BankTransferEvent.ShowIfscSearch) + } + is BankTransferAction.Continue -> { + if (state.isFormValid) { + 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 -> { + 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) + } + is BankTransferAction.SelectIfscCode -> { + mutableStateFlow.update { it.copy(ifscCode = action.ifscCode.code) } + sendEvent(BankTransferEvent.IfscCodeSelected(action.ifscCode)) + } + is BankTransferAction.UpdateReenterAccountNumber -> { + 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) } + } + } + } + } +} + +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 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.isLetterOrDigit() } + + val isIfscCodeValid: Boolean + 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 && + 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", + ), + ) + + 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 { + data object NavigateBack : BankTransferEvent + data object ShowIfscSearch : BankTransferEvent + data object NavigateToNext : BankTransferEvent + data object AddBankAccount : BankTransferEvent + data class IfscCodeSelected(val ifscCode: IfscCode) : 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 + 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 + 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/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt new file mode 100644 index 000000000..59a4fdc13 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -0,0 +1,526 @@ +/* + * 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.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.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 +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 +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.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 +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 +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 -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + 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) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xs)) + + 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)) + } + + ProceedButton( + state = state, + onProceedClick = { + viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = KptTheme.spacing.lg, + bottom = 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, + ) { + 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() && state.payeeName != "UNKNOWN") { + val decodedName = state.payeeName + .replace("%20", " ") + .trim() + + Text( + text = "Paying ${decodedName.uppercase()}", + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + val contactInfo = if (state.isUpiCode) { + "UPI ID: ${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, + onNoteFieldFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + 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, + onFieldFocused = onNoteFieldFocused, + modifier = Modifier.wrapContentWidth(), + ) + } +} + +// TODO improve amount validation and UI/UX +@Composable +private fun ExpandableAmountInput( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + 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 + .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, + ) { + Icon( + imageVector = MifosIcons.CurrencyRupee, + contentDescription = "Rupee Icon", + tint = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + BasicTextField( + value = displayValue, + onValueChange = { newValue -> + 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.Decimal), + textStyle = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width(textFieldWidth) + .focusRequester(focusRequester), + singleLine = true, + ) + } + } +} + +// 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( + 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) { + 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) + .onFocusChanged { focusState -> + if (focusState.isFocused && !isFocused) { + isFocused = true + onFieldFocused() + } + }, + 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() + }, + ) + } + } +} + +// TODO improve UI/UX of proceed button +@Composable +private fun ProceedButton( + state: PayeeDetailsState, + onProceedClick: () -> Unit, + modifier: Modifier = Modifier, +) { + 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.size(56.dp), + colors = ButtonDefaults.buttonColors( + 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), + ) { + 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 new file mode 100644 index 000000000..87baf0e9e --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -0,0 +1,200 @@ +/* + * 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 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 + +class PayeeDetailsViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = PayeeDetailsState(), +) { + + init { + val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" + + if (safeQrCodeDataString.isNotEmpty()) { + val qrCodeDataString = safeQrCodeDataString.urlDecode() + val isUpiCode = StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString) + + val qrCodeData = if (isUpiCode) { + StandardUpiQrCodeProcessor.parseUpiQrCode(qrCodeDataString) + } else { + 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 -> { + 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, + ) + + 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) { + 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, + val showMaxAmountMessage: Boolean = false, + val hasNoteFieldBeenFocused: 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 if (isUpiCode) "0.00" else "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 (isUpiCode) { + val paddedDecimalPart = decimalPart.padEnd(2, '0').take(2) + "$formattedInteger.$paddedDecimalPart" + } else { + if (decimalPart.isNotEmpty()) { + "$formattedInteger.$decimalPart" + } else { + formattedInteger + } + } + } catch (e: NumberFormatException) { + amountStr + } + } +} + +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 NoteFieldFocused : 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/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/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt new file mode 100644 index 000000000..aaa28a8c1 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,470 @@ +/* + * 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.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_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, + onNavigateToPayeeDetails: (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) + } + is SendMoneyOptionsEvent.NavigateToPayeeDetails -> { + onNavigateToPayeeDetails.invoke(event.qrCodeData) + } + } + } + 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 = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} 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..0e82e041e --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -0,0 +1,77 @@ +/* + * 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.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BackgroundEvent +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 -> + // 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) + } + 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, BackgroundEvent + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyOptionsEvent, BackgroundEvent +} + +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/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 66d7bebd2..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 @@ -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 @@ -92,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, @@ -108,7 +108,16 @@ fun SendMoneyScreen( navigateToTransferScreen(event.data) } + is SendMoneyEvent.NavigateToPayeeDetails -> { + navigateToPayeeDetails(event.qrCodeData) + } + is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() + + is SendMoneyEvent.ShowToast -> { + // TODO: Implement toast message display + // For now, we'll just ignore it + } } } @@ -130,7 +139,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..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 @@ -25,23 +25,26 @@ 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 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 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 @@ -120,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 @@ -176,7 +183,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 +201,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( @@ -210,7 +228,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() && @@ -229,19 +247,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 } @@ -260,6 +275,9 @@ 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 } sealed interface SendMoneyAction { 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..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 @@ -11,10 +11,18 @@ 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.SearchIfscViewModel +import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + 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 04af30a0a..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 @@ -14,7 +14,14 @@ 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.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 +import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen const val SEND_MONEY_ROUTE = "send_money_route" @@ -22,13 +29,46 @@ 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 SEARCH_IFSC_ROUTE = "search_ifsc_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, ) = navigate(SEND_MONEY_ROUTE, navOptions) +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.navigateToSearchIfscScreen( + navOptions: NavOptions? = null, +) = navigate(SEARCH_IFSC_ROUTE, navOptions) + +fun NavController.navigateToPayeeDetailsScreen( + qrCodeData: String, + navOptions: NavOptions? = null, +) { + // 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, + navigateToPayeeDetailsScreen: (String) -> Unit, navigateToScanQrScreen: () -> Unit, ) { composableWithSlideTransitions( @@ -45,6 +85,81 @@ fun NavGraphBuilder.sendMoneyScreen( onBackClick = onBackClick, navigateToTransferScreen = navigateToTransferScreen, navigateToScanQrScreen = navigateToScanQrScreen, + navigateToPayeeDetails = navigateToPayeeDetailsScreen, + ) + } +} + +fun NavGraphBuilder.sendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + ) + } +} + +fun NavGraphBuilder.bankTransferScreen( + onBackClick: () -> Unit, + onSearchIfscClick: () -> Unit, +) { + composableWithSlideTransitions( + route = BANK_TRANSFER_ROUTE, + ) { + BankTransferScreen( + onBackClick = onBackClick, + onSearchIfscClick = onSearchIfscClick, + ) + } +} + +fun NavGraphBuilder.searchIfscScreen( + onBackClick: () -> Unit, + onIfscSelected: (IfscCode) -> Unit, +) { + composableWithSlideTransitions( + route = SEARCH_IFSC_ROUTE, + ) { + SearchIfscScreen( + onBackClick = onBackClick, + onIfscSelected = onIfscSelected, + ) + } +} + +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, ) } } @@ -54,9 +169,47 @@ 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) } + +/** + * 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") +}