diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index f666a3614..d46291228 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -3012,6 +3012,38 @@ | | +--- org.jetbrains.compose.material3:material3:1.8.2 (*) | | +--- org.jetbrains.compose.components:components-resources:1.8.2 (*) | | \--- org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 (*) +| +--- project :feature:autopay +| | +--- androidx.lifecycle:lifecycle-runtime-compose:2.9.2 (*) +| | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2 (*) +| | +--- androidx.tracing:tracing-ktx:1.3.0 (*) +| | +--- io.insert-koin:koin-bom:4.1.0 (*) +| | +--- io.insert-koin:koin-android:4.1.0 (*) +| | +--- io.insert-koin:koin-androidx-compose:4.1.0 (*) +| | +--- io.insert-koin:koin-androidx-navigation:4.1.0 (*) +| | +--- io.insert-koin:koin-core-viewmodel:4.1.0 (*) +| | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.1.21 (*) +| | +--- io.insert-koin:koin-core:4.1.0 (*) +| | +--- io.insert-koin:koin-annotations:2.1.0 (*) +| | +--- project :core:ui (*) +| | +--- project :core:designsystem (*) +| | +--- project :core:data (*) +| | +--- io.insert-koin:koin-compose:4.1.0 (*) +| | +--- io.insert-koin:koin-compose-viewmodel:4.1.0 (*) +| | +--- org.jetbrains.compose.runtime:runtime:1.8.2 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.1 (*) +| | +--- org.jetbrains.androidx.savedstate:savedstate:1.3.1 (*) +| | +--- org.jetbrains.androidx.core:core-bundle:1.0.1 (*) +| | +--- org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 -> 1.8.0 (*) +| | +--- org.jetbrains.compose.ui:ui:1.8.2 (*) +| | +--- org.jetbrains.compose.foundation:foundation:1.8.2 (*) +| | +--- org.jetbrains.compose.material3:material3:1.8.2 (*) +| | +--- org.jetbrains.compose.components:components-resources:1.8.2 (*) +| | \--- org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 (*) | \--- org.jetbrains.kotlin:kotlin-parcelize-runtime:2.1.20 (*) +--- project :core:data (*) +--- project :core:ui (*) diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt index 89aaf0d21..7d2e8a654 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt @@ -12,6 +12,7 @@ :core:ui :feature:accounts :feature:auth +:feature:autopay :feature:editpassword :feature:faq :feature:finance diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 91ced69b5..522e01de8 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.2-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/build.gradle.kts b/cmp-shared/build.gradle.kts index 0b8ef8a66..0c2ef4174 100644 --- a/cmp-shared/build.gradle.kts +++ b/cmp-shared/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { implementation(projects.feature.qr) implementation(projects.feature.merchants) implementation(projects.feature.upiSetup) + implementation(projects.feature.autopay) } desktopMain.dependencies { diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt index b87dc3783..9d8cb8366 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt @@ -24,6 +24,7 @@ import org.mifospay.core.network.di.LocalModule import org.mifospay.core.network.di.NetworkModule import org.mifospay.feature.accounts.di.AccountsModule import org.mifospay.feature.auth.di.AuthModule +import org.mifospay.feature.autopay.di.AutoPayModule import org.mifospay.feature.editpassword.di.EditPasswordModule import org.mifospay.feature.faq.di.FaqModule import org.mifospay.feature.history.di.HistoryModule @@ -88,6 +89,7 @@ object KoinModules { QrModule, MerchantsModule, UpiSetupModule, + AutoPayModule, ) } private val LibraryModule = module { 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..9adc2ca65 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 @@ -22,6 +22,13 @@ import org.mifospay.feature.accounts.savingsaccount.addEditSavingAccountScreen import org.mifospay.feature.accounts.savingsaccount.details.navigateToSavingAccountDetails import org.mifospay.feature.accounts.savingsaccount.details.savingAccountDetailRoute import org.mifospay.feature.accounts.savingsaccount.navigateToSavingAccountAddEdit +import org.mifospay.feature.autopay.AutoPayScreen +import org.mifospay.feature.autopay.autoPayGraph +import org.mifospay.feature.autopay.navigateToAutoPay +import org.mifospay.feature.autopay.navigateToAutoPayHistory +import org.mifospay.feature.autopay.navigateToAutoPayPreferences +import org.mifospay.feature.autopay.navigateToAutoPayRules +import org.mifospay.feature.autopay.navigateToAutoPaySetup import org.mifospay.feature.editpassword.navigation.editPasswordScreen import org.mifospay.feature.editpassword.navigation.navigateToEditPassword import org.mifospay.feature.faq.navigation.faqScreen @@ -72,7 +79,12 @@ 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.navigateToPayeeDetailsScreen +import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.payeeDetailsScreen +import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen import org.mifospay.feature.standing.instruction.StandingInstructionsScreen @@ -97,6 +109,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -121,6 +134,22 @@ internal fun MifosNavHost( navigateToInvoiceDetailScreen = navController::navigateToInvoiceDetail, ) }, + TabContent(PaymentsScreenContents.AUTOPAY.name) { + AutoPayScreen( + onNavigateToSetup = { + navController.navigateToAutoPaySetup() + }, + onNavigateToRules = { + navController.navigateToAutoPayRules() + }, + onNavigateToPreferences = { + navController.navigateToAutoPayPreferences() + }, + onNavigateToHistory = { + navController.navigateToAutoPayHistory() + }, + ) + }, ) val tabContents = listOf( @@ -160,7 +189,10 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, + onAutoPay = { + navController.navigateToAutoPay() + }, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -279,12 +311,55 @@ internal fun MifosNavHost( navigateBack = navController::navigateUp, ) + sendMoneyOptionsScreen( + onBackClick = navController::popBackStack, + onScanQrClick = { + // This is now handled by the ViewModel using ML Kit scanner + }, + onPayAnyoneClick = { + // TODO: Navigate to Pay Anyone screen + }, + onBankTransferClick = { + // TODO: Navigate to Bank Transfer screen + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onAutoPayClick = { + navController.navigateToAutoPay() + }, + 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, ) + 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 +397,16 @@ internal fun MifosNavHost( }, ) }, + navigateToPayeeDetailsScreen = { + navController.navigateToPayeeDetailsScreen( + qrCodeData = it, + navOptions = navOptions { + popUpTo(SCAN_QR_ROUTE) { + inclusive = true + } + }, + ) + }, ) merchantTransferScreen( @@ -332,5 +417,10 @@ internal fun MifosNavHost( setupUpiPinScreen( navigateBack = navController::navigateUp, ) + + autoPayGraph( + navController = navController, + onNavigateBack = navController::navigateUp, + ) } } 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..6c9ac6f67 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,11 +23,13 @@ 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 import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Person @@ -129,4 +132,9 @@ 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 + val History = Icons.Filled.History } 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/autopay/README.md b/feature/autopay/README.md new file mode 100644 index 000000000..be939c42e --- /dev/null +++ b/feature/autopay/README.md @@ -0,0 +1,15 @@ +# AutoPay Feature + +## Overview +The AutoPay feature module provides functionality for setting up and managing automatic payment schedules. This module allows users to configure recurring payments, set up payment rules, and manage their automatic payment preferences. + +## Screenshots +### Android +*Screenshots will be added as the feature is developed* + +### Desktop +*Screenshots will be added as the feature is developed* + +### Web +*Screenshots will be added as the feature is developed* + diff --git a/feature/autopay/build.gradle.kts b/feature/autopay/build.gradle.kts new file mode 100644 index 000000000..6b0eaec56 --- /dev/null +++ b/feature/autopay/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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 + */ +plugins { + alias(libs.plugins.cmp.feature.convention) +} + +android { + namespace = "org.mifospay.feature.autopay" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt new file mode 100644 index 000000000..85af684c2 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt @@ -0,0 +1,54 @@ +/* + * 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.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPayHistoryScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay History", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "View your automatic payment history", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt new file mode 100644 index 000000000..92a53011d --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt @@ -0,0 +1,90 @@ +/* + * 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.autopay + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.navOptions + +object AutoPayNavigation { + const val AUTO_PAY_ROUTE = "autopay" + const val AUTO_PAY_SETUP_ROUTE = "autopay/setup" + const val AUTO_PAY_RULES_ROUTE = "autopay/rules" + const val AUTO_PAY_PREFERENCES_ROUTE = "autopay/preferences" + const val AUTO_PAY_HISTORY_ROUTE = "autopay/history" +} + +fun NavController.navigateToAutoPay(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPaySetup(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_SETUP_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayRules(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_RULES_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayPreferences(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayHistory(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE, navOptions) +} + +fun NavGraphBuilder.autoPayGraph( + navController: NavController, + onNavigateBack: () -> Unit = { navController.navigateUp() }, +) { + composable(AutoPayNavigation.AUTO_PAY_ROUTE) { + AutoPayScreen( + onNavigateToSetup = { + navController.navigateToAutoPaySetup() + }, + onNavigateToRules = { + navController.navigateToAutoPayRules() + }, + onNavigateToPreferences = { + navController.navigateToAutoPayPreferences() + }, + onNavigateToHistory = { + navController.navigateToAutoPayHistory() + }, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_SETUP_ROUTE) { + AutoPaySetupScreen( + onNavigateBack = onNavigateBack, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_RULES_ROUTE) { + AutoPayRulesScreen( + onNavigateBack = onNavigateBack, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE) { + AutoPayPreferencesScreen( + onNavigateBack = onNavigateBack, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE) { + AutoPayHistoryScreen( + onNavigateBack = onNavigateBack, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt new file mode 100644 index 000000000..2b2330fb4 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt @@ -0,0 +1,54 @@ +/* + * 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.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPayPreferencesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay Preferences", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Customize your AutoPay settings", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt new file mode 100644 index 000000000..3106a196b --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt @@ -0,0 +1,54 @@ +/* + * 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.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPayRulesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay Rules", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Manage your payment conditions and schedules", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt new file mode 100644 index 000000000..bdb35b727 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt @@ -0,0 +1,156 @@ +/* + * 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.autopay + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.icon.MifosIcons + +@Composable +fun AutoPayScreen( + onNavigateToSetup: () -> Unit, + onNavigateToRules: () -> Unit, + onNavigateToPreferences: () -> Unit, + onNavigateToHistory: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AutoPayViewModel = koinViewModel(), +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "AutoPay", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Manage your automatic payments", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + AutoPayOptionCard( + title = "Setup AutoPay", + description = "Configure automatic payment rules", + icon = MifosIcons.Settings, + onClick = onNavigateToSetup, + ) + + AutoPayOptionCard( + title = "Payment Rules", + description = "Manage payment conditions and schedules", + icon = MifosIcons.Payment, + onClick = onNavigateToRules, + ) + + AutoPayOptionCard( + title = "Preferences", + description = "Customize AutoPay settings", + icon = MifosIcons.Profile, + onClick = onNavigateToPreferences, + ) + + AutoPayOptionCard( + title = "History", + description = "View AutoPay transaction history", + icon = MifosIcons.History, + onClick = onNavigateToHistory, + ) + } + } +} + +@Composable +private fun AutoPayOptionCard( + title: String, + description: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt new file mode 100644 index 000000000..d7ad18f56 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt @@ -0,0 +1,54 @@ +/* + * 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.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPaySetupScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay Setup", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Configure your automatic payment rules", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt new file mode 100644 index 000000000..8f782e2e3 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt @@ -0,0 +1,88 @@ +/* + * 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.autopay + +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayViewModel : BaseViewModel( + initialState = AutoPayState(), +) { + + override fun handleAction(action: AutoPayAction) { + when (action) { + is AutoPayAction.SetupRecurringPayment -> { + setupRecurringPayment() + } + is AutoPayAction.ConfigurePaymentRules -> { + configurePaymentRules() + } + is AutoPayAction.ManagePaymentPreferences -> { + managePaymentPreferences() + } + is AutoPayAction.ToggleAutoPay -> { + toggleAutoPay(action.enabled) + } + is AutoPayAction.GetPaymentHistory -> { + getPaymentHistory() + } + } + } + + private fun setupRecurringPayment() { + // TODO: Implement recurring payment setup logic + sendEvent(AutoPayEvent.NavigateToSetup) + } + + private fun configurePaymentRules() { + // TODO: Implement payment rules configuration + sendEvent(AutoPayEvent.NavigateToRules) + } + + private fun managePaymentPreferences() { + // TODO: Implement payment preferences management + sendEvent(AutoPayEvent.NavigateToPreferences) + } + + private fun toggleAutoPay(enabled: Boolean) { + // TODO: Implement auto-pay toggle logic + mutableStateFlow.update { + it.copy(isAutoPayEnabled = enabled) + } + } + + private fun getPaymentHistory() { + // TODO: Implement payment history retrieval + sendEvent(AutoPayEvent.NavigateToHistory) + } +} + +@Serializable +data class AutoPayState( + val isAutoPayEnabled: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, +) + +sealed interface AutoPayEvent { + data object NavigateToSetup : AutoPayEvent + data object NavigateToRules : AutoPayEvent + data object NavigateToPreferences : AutoPayEvent + data object NavigateToHistory : AutoPayEvent +} + +sealed interface AutoPayAction { + data object SetupRecurringPayment : AutoPayAction + data object ConfigurePaymentRules : AutoPayAction + data object ManagePaymentPreferences : AutoPayAction + data object GetPaymentHistory : AutoPayAction + data class ToggleAutoPay(val enabled: Boolean) : AutoPayAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt new file mode 100644 index 000000000..34f148218 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt @@ -0,0 +1,18 @@ +/* + * 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.autopay.di + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module +import org.mifospay.feature.autopay.AutoPayViewModel + +val AutoPayModule = module { + viewModelOf(::AutoPayViewModel) +} diff --git a/feature/home/src/commonMain/composeResources/values/strings.xml b/feature/home/src/commonMain/composeResources/values/strings.xml index 6c0266da6..4c44be41b 100644 --- a/feature/home/src/commonMain/composeResources/values/strings.xml +++ b/feature/home/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,7 @@ Request Money Send Send Money + AutoPay Coin Account type diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt index a2b31672f..eea5654fe 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt @@ -78,6 +78,7 @@ import mobile_wallet.feature.home.generated.resources.arrow_backward import mobile_wallet.feature.home.generated.resources.coin_image import mobile_wallet.feature.home.generated.resources.feature_home_account_type import mobile_wallet.feature.home.generated.resources.feature_home_arrow_up +import mobile_wallet.feature.home.generated.resources.feature_home_autopay import mobile_wallet.feature.home.generated.resources.feature_home_coin_image import mobile_wallet.feature.home.generated.resources.feature_home_desc import mobile_wallet.feature.home.generated.resources.feature_home_loading @@ -124,6 +125,7 @@ internal fun HomeScreen( onNavigateBack: () -> Unit, onRequest: (String) -> Unit, onPay: () -> Unit, + onAutoPay: () -> Unit, navigateToTransactionDetail: (Long, Long) -> Unit, navigateToAccountDetail: (Long) -> Unit, modifier: Modifier = Modifier, @@ -141,6 +143,7 @@ internal fun HomeScreen( is HomeEvent.NavigateBack -> onNavigateBack.invoke() is HomeEvent.NavigateToRequestScreen -> onRequest(event.vpa) is HomeEvent.NavigateToSendScreen -> onPay.invoke() + is HomeEvent.NavigateToAutoPayScreen -> onAutoPay.invoke() is HomeEvent.NavigateToClientDetailScreen -> {} is HomeEvent.NavigateToTransactionDetail -> { navigateToTransactionDetail(event.accountId, event.transactionId) @@ -277,6 +280,9 @@ private fun HomeScreenContent( onSend = { onAction(HomeAction.SendClicked) }, + onAutoPay = { + onAction(HomeAction.AutoPayClicked) + }, ) } @@ -507,45 +513,66 @@ fun CardDropdownBox( private fun PayRequestScreen( onRequest: () -> Unit, onSend: () -> Unit, + onAutoPay: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + Column( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - PaymentButton( - modifier = Modifier - .weight(1f) - .height(55.dp), - text = stringResource(Res.string.feature_home_request), - onClick = onRequest, - leadingIcon = { - Icon( - modifier = Modifier - .size(26.dp), - imageVector = vectorResource( - Res.drawable.arrow_backward, - ), - contentDescription = stringResource(Res.string.feature_home_request_money), - ) - }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PaymentButton( + modifier = Modifier + .weight(1f) + .height(55.dp), + text = stringResource(Res.string.feature_home_request), + onClick = onRequest, + leadingIcon = { + Icon( + modifier = Modifier + .size(26.dp), + imageVector = vectorResource( + Res.drawable.arrow_backward, + ), + contentDescription = stringResource(Res.string.feature_home_request_money), + ) + }, + ) + + Spacer(modifier = Modifier.width(20.dp)) - Spacer(modifier = Modifier.width(20.dp)) + PaymentButton( + modifier = Modifier + .weight(1f) + .height(55.dp), + text = stringResource(Res.string.feature_home_send), + onClick = onSend, + leadingIcon = { + Icon( + modifier = Modifier + .size(26.dp) + .graphicsLayer(rotationZ = 180f), + imageVector = vectorResource(Res.drawable.arrow_backward), + contentDescription = stringResource(Res.string.feature_home_send_money), + ) + }, + ) + } PaymentButton( modifier = Modifier - .weight(1f) + .fillMaxWidth() .height(55.dp), - text = stringResource(Res.string.feature_home_send), - onClick = onSend, + text = stringResource(Res.string.feature_home_autopay), + onClick = onAutoPay, leadingIcon = { Icon( - modifier = Modifier - .size(26.dp) - .graphicsLayer(rotationZ = 180f), - imageVector = vectorResource(Res.drawable.arrow_backward), - contentDescription = stringResource(Res.string.feature_home_send_money), + modifier = Modifier.size(26.dp), + imageVector = MifosIcons.Payment, + contentDescription = stringResource(Res.string.feature_home_autopay), ) }, ) diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt index 35b00c469..68db98948 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt @@ -117,6 +117,10 @@ class HomeViewModel( sendEvent(HomeEvent.NavigateToSendScreen) } + is HomeAction.AutoPayClicked -> { + sendEvent(HomeEvent.NavigateToAutoPayScreen) + } + is HomeAction.ClientDetailsClicked -> { sendEvent(HomeEvent.NavigateToClientDetailScreen) } @@ -218,6 +222,7 @@ sealed interface ViewState { sealed interface HomeEvent { data object NavigateBack : HomeEvent data object NavigateToSendScreen : HomeEvent + data object NavigateToAutoPayScreen : HomeEvent data object NavigateToTransactionScreen : HomeEvent data object NavigateToClientDetailScreen : HomeEvent data class NavigateToRequestScreen(val vpa: String) : HomeEvent @@ -230,6 +235,7 @@ sealed interface HomeEvent { sealed interface HomeAction { data object RequestClicked : HomeAction data object SendClicked : HomeAction + data object AutoPayClicked : HomeAction data object ClientDetailsClicked : HomeAction data object OnClickSeeAllTransactions : HomeAction data object OnDismissDialog : HomeAction diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt index 5ea8e9776..e028a5192 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt @@ -23,6 +23,7 @@ fun NavGraphBuilder.homeScreen( onNavigateBack: () -> Unit, onRequest: (String) -> Unit, onPay: () -> Unit, + onAutoPay: () -> Unit, navigateToTransactionDetail: (Long, Long) -> Unit, navigateToAccountDetail: (Long) -> Unit, ) { @@ -30,6 +31,7 @@ fun NavGraphBuilder.homeScreen( HomeScreen( onRequest = onRequest, onPay = onPay, + onAutoPay = onAutoPay, onNavigateBack = onNavigateBack, navigateToTransactionDetail = navigateToTransactionDetail, navigateToAccountDetail = navigateToAccountDetail, 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/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt b/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt index 66263af9c..59e9b02bd 100644 --- a/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt +++ b/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt @@ -62,6 +62,7 @@ enum class PaymentsScreenContents { HISTORY, SI, INVOICES, + AUTOPAY, } @Preview 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..a1ea3ebb2 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,15 @@ 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 + Fineract Payments + People + Merchants + More + AutoPay \ No newline at end of file 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/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt new file mode 100644 index 000000000..71db8acbe --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,494 @@ +/* + * 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_autopay +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, + onAutoPayClick: () -> 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() + } + SendMoneyOptionsEvent.NavigateToAutoPay -> { + onAutoPayClick.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) + }, + onAutoPayClick = { + viewModel.trySendAction(SendMoneyOptionsAction.AutoPayClicked) + }, + ) + + 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, + onAutoPayClick: () -> 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), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.CalenderMonth, + label = stringResource(Res.string.feature_send_money_autopay), + onClick = onAutoPayClick, + modifier = Modifier.weight(1f), + ) + + // Empty space for future icons (UPI Lite, Tap & Pay, etc.) + Spacer(modifier = Modifier.weight(3f)) + } + } +} + +@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..0df5f00f6 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -0,0 +1,82 @@ +/* + * 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) + } + is SendMoneyOptionsAction.AutoPayClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToAutoPay) + } + } + } +} + +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 object NavigateToAutoPay : 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 + data object AutoPayClicked : 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..8af69abde 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -11,10 +11,14 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.send.money.PayeeDetailsViewModel import org.mifospay.feature.send.money.ScannerModule +import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + viewModelOf(::SendMoneyOptionsViewModel) + viewModelOf(::PayeeDetailsViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 04af30a0a..74defd7a4 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,11 @@ 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.PayeeDetailsScreen +import org.mifospay.feature.send.money.PayeeDetailsState +import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen const val SEND_MONEY_ROUTE = "send_money_route" @@ -22,13 +26,36 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" +const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" +const val PAYEE_DETAILS_ROUTE = "payee_details_route" +const val PAYEE_DETAILS_ARG = "qrCodeData" + +const val PAYEE_DETAILS_BASE_ROUTE = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG={$PAYEE_DETAILS_ARG}" + fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_ROUTE, navOptions) +fun NavController.navigateToSendMoneyOptionsScreen( + navOptions: NavOptions? = null, +) = navigate(SEND_MONEY_OPTIONS_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 +72,55 @@ fun NavGraphBuilder.sendMoneyScreen( onBackClick = onBackClick, navigateToTransferScreen = navigateToTransferScreen, navigateToScanQrScreen = navigateToScanQrScreen, + navigateToPayeeDetails = navigateToPayeeDetailsScreen, + ) + } +} + +fun NavGraphBuilder.sendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onAutoPayClick = onAutoPayClick, + onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + ) + } +} + +fun NavGraphBuilder.payeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, +) { + composableWithSlideTransitions( + route = PAYEE_DETAILS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYEE_DETAILS_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + PayeeDetailsScreen( + onBackClick = onBackClick, + onNavigateToUpiPayment = onNavigateToUpiPayment, + onNavigateToFineractPayment = onNavigateToFineractPayment, ) } } @@ -54,9 +130,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") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 63c88a74f..bc520718e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -83,5 +83,6 @@ include(":feature:payments") include(":feature:request-money") include(":feature:upi-setup") include(":feature:qr") +include(":feature:autopay") include(":libs:mifos-passcode")