diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index f666a3614..905ef1e31 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -2760,6 +2760,11 @@ | | | | +--- com.google.firebase:firebase-encoders:16.1.0 -> 17.0.0 (*) | | | | \--- com.google.firebase:firebase-encoders-json:17.1.0 -> 18.0.1 (*) | | | \--- com.google.mlkit:common:18.9.0 -> 18.11.0 (*) +| | +--- com.google.accompanist:accompanist-permissions:0.36.0 +| | | +--- androidx.activity:activity-compose:1.9.0 -> 1.10.1 (*) +| | | +--- androidx.compose.foundation:foundation:1.7.0 -> 1.8.3 (*) +| | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4 -> 1.10.2 (*) +| | | \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.22 -> 2.1.21 (*) | | +--- 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 (*) @@ -2896,11 +2901,7 @@ | | | +--- androidx.camera:camera-video:1.4.2 (c) | | | \--- androidx.camera:camera-view:1.4.2 (c) | | +--- androidx.camera:camera-lifecycle:1.4.2 (*) -| | +--- com.google.accompanist:accompanist-permissions:0.36.0 -| | | +--- androidx.activity:activity-compose:1.9.0 -> 1.10.1 (*) -| | | +--- androidx.compose.foundation:foundation:1.7.0 -> 1.8.3 (*) -| | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4 -> 1.10.2 (*) -| | | \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.22 -> 2.1.21 (*) +| | +--- com.google.accompanist:accompanist-permissions:0.36.0 (*) | | +--- com.google.mlkit:barcode-scanning:17.3.0 | | | +--- com.google.android.gms:play-services-basement:18.4.0 -> 18.5.0 (*) | | | +--- com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1 diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 91ced69b5..2c87e7142 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.13' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' @@ -6,6 +6,7 @@ uses-permission: name='android.permission.CAMERA' uses-permission: name='android.permission.READ_EXTERNAL_STORAGE' maxSdkVersion='32' uses-permission: name='android.permission.WRITE_EXTERNAL_STORAGE' maxSdkVersion='32' uses-permission: name='android.permission.VIBRATE' +uses-permission: name='android.permission.READ_CONTACTS' uses-permission: name='android.permission.FLASHLIGHT' uses-permission: name='android.permission.ACCESS_NETWORK_STATE' uses-permission: name='android.permission.USE_BIOMETRIC' 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..9a948f44b 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 @@ -71,8 +71,18 @@ import org.mifospay.feature.savedcards.createOrUpdate.navigateToCardAddEdit 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.PAY_ANYONE_ROUTE 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.contactsPickerScreen +import org.mifospay.feature.send.money.navigation.navigateToContactsPickerScreen +import org.mifospay.feature.send.money.navigation.navigateToPayAnyoneScreen +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.payAnyoneScreen +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 +107,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -160,7 +171,7 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -279,12 +290,76 @@ internal fun MifosNavHost( navigateBack = navController::navigateUp, ) + sendMoneyOptionsScreen( + onBackClick = navController::popBackStack, + onScanQrClick = { + // This is now handled by the ViewModel using ML Kit scanner + }, + onPayAnyoneClick = { + navController.navigateToPayAnyoneScreen() + }, + onBankTransferClick = { + // TODO: Navigate to Bank Transfer screen + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onQrCodeScanned = { qrData -> + navController.navigateToSendMoneyScreen( + requestData = qrData, + navOptions = navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { + inclusive = true + } + }, + ) + }, + onNavigateToPayeeDetails = { qrCodeData -> + navController.navigateToPayeeDetailsScreen(qrCodeData) + }, + ) + sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, + navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen, navigateToScanQrScreen = navController::navigateToScanQr, ) + payAnyoneScreen( + onBackClick = navController::popBackStack, + onContactPickerClick = { + navController.navigateToContactsPickerScreen() + }, + onContactSelected = { phoneNumber -> + // Contact selection updates the input field via ViewModel + // No navigation needed - user stays on Pay Anyone screen + }, + ) + + contactsPickerScreen( + onBackClick = navController::popBackStack, + onContactSelected = { phoneNumber -> + // Navigate back to Pay Anyone screen with selected phone number + navController.navigateToPayAnyoneScreen( + selectedContactPhone = phoneNumber, + navOptions = navOptions { + popUpTo(PAY_ANYONE_ROUTE) { inclusive = true } + }, + ) + }, + ) + + 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( diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt new file mode 100644 index 000000000..545f7b574 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import org.mifospay.core.model.utils.PaymentQrData +import org.mifospay.core.model.utils.StandardUpiQrData + +/** + * Standard UPI QR Code Processor + * Handles parsing of standard UPI QR codes according to UPI specification + */ +object StandardUpiQrCodeProcessor { + + /** + * Checks if the given string is a valid UPI QR code + * @param qrData The QR code data string + * @return true if it's a valid UPI QR code, false otherwise + */ + fun isValidUpiQrCode(qrData: String): Boolean { + return qrData.startsWith("upi://") || qrData.startsWith("UPI://") + } + + /** + * Parses a standard UPI QR code string + * @param qrData The QR code data string + * @return StandardUpiQrData object with parsed information + * @throws IllegalArgumentException if the QR code is invalid + */ + fun parseUpiQrCode(qrData: String): StandardUpiQrData { + if (!isValidUpiQrCode(qrData)) { + throw IllegalArgumentException("Invalid UPI QR code format") + } + + val paramsString = qrData.substringAfter("upi://").substringAfter("UPI://") + val parts = paramsString.split("?", limit = 2) + val params = if (parts.size > 1) parseParams(parts[1]) else emptyMap() + + val payeeVpa = params["pa"] ?: run { + throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + } + val payeeName = params["pn"] ?: "Unknown" + + val vpaParts = payeeVpa.split("@", limit = 2) + val actualVpa = if (vpaParts.size == 2) payeeVpa else payeeVpa + + return StandardUpiQrData( + payeeName = payeeName, + payeeVpa = actualVpa, + amount = params["am"] ?: "", + currency = params["cu"] ?: StandardUpiQrData.DEFAULT_CURRENCY, + transactionNote = params["tn"] ?: "", + merchantCode = params["mc"] ?: "", + transactionReference = params["tr"] ?: "", + url = params["url"] ?: "", + mode = params["mode"] ?: "02", + ) + } + + /** + * Parses URL parameters into a map + * @param paramsString The parameters string + * @return Map of parameter keys and values + */ + private fun parseParams(paramsString: String): Map { + return paramsString + .split("&") + .associate { param -> + val keyValue = param.split("=", limit = 2) + if (keyValue.size == 2) { + keyValue[0] to keyValue[1] + } else { + param to "" + } + } + } + + /** + * Converts StandardUpiQrData to PaymentQrData for compatibility with existing code + * @param standardData Standard UPI QR data + * @return PaymentQrData object + * Note: clientId and accountId not available in standard UPI + */ + fun toPaymentQrData(standardData: StandardUpiQrData): PaymentQrData { + return PaymentQrData( + clientId = 0, + clientName = standardData.payeeName, + accountNo = standardData.payeeVpa, + amount = standardData.amount, + accountId = 0, + currency = standardData.currency, + officeId = 1, + accountTypeId = 2, + ) + } +} diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index 5bca46905..005e46a97 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -11,6 +11,7 @@ package org.mifospay.core.designsystem.icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.AttachMoney @@ -22,6 +23,7 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CurrencyRupee import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit @@ -129,4 +131,8 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + + val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward + + val CurrencyRupee = Icons.Filled.CurrencyRupee } diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt new file mode 100644 index 000000000..861d4c6bb --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.utils + +import kotlinx.serialization.Serializable + +/** + * Data class representing standard UPI QR code data + * Based on UPI QR code specification + */ +@Serializable +data class StandardUpiQrData( + val payeeName: String, + val payeeVpa: String, + val amount: String = "", + val currency: String = "INR", + val transactionNote: String = "", + val merchantCode: String = "", + val transactionReference: String = "", + val url: String = "", + // 02 for QR code + val mode: String = "02", +) { + companion object { + const val DEFAULT_CURRENCY = "INR" + } +} diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt index 7df3fb44d..fe7c7a09d 100644 --- a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.make_transfer.generated.resources.Res import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_amount import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_description @@ -207,7 +208,7 @@ internal data class MakeTransferState( val amount: String = toClientData.amount, val description: String = "", val selectedAccount: Account? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && amount.toDoubleOrNull() != null @@ -232,12 +233,9 @@ internal data class MakeTransferState( transferDate = DateHelper.formattedShortDate, ) - @Serializable sealed interface DialogState { - @Serializable data object Loading : DialogState - @Serializable sealed interface Error : DialogState { data class StringMessage(val message: String) : Error data class ResourceMessage(val message: StringResource) : Error diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt index 66513f281..0cbfbadb1 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt @@ -30,6 +30,7 @@ import org.mifospay.core.designsystem.component.MifosScaffold internal fun ScanQrCodeScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, modifier: Modifier = Modifier, viewModel: ScanQrViewModel = koinViewModel(), ) { @@ -44,6 +45,10 @@ internal fun ScanQrCodeScreen( navigateToSendScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToSendScreen).data) } + is ScanQrEvent.OnNavigateToPayeeDetails -> { + navigateToPayeeDetailsScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToPayeeDetails).data) + } + is ScanQrEvent.ShowToast -> { scope.launch { snackbarHostState.showSnackbar((eventFlow as ScanQrEvent.ShowToast).message) diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index cea7f82b7..33b8d7e20 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor class ScanQrViewModel : ViewModel() { @@ -22,10 +23,24 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - UpiQrCodeProcessor.decodeUpiString(data) + val isUpiQr = try { + UpiQrCodeProcessor.decodeUpiString(data) + true + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(data) + true + } else { + false + } + } _eventFlow.update { - ScanQrEvent.OnNavigateToSendScreen(data) + if (isUpiQr) { + ScanQrEvent.OnNavigateToPayeeDetails(data) + } else { + ScanQrEvent.OnNavigateToSendScreen(data) + } } true @@ -40,5 +55,6 @@ class ScanQrViewModel : ViewModel() { sealed interface ScanQrEvent { data class OnNavigateToSendScreen(val data: String) : ScanQrEvent + data class OnNavigateToPayeeDetails(val data: String) : ScanQrEvent data class ShowToast(val message: String) : ScanQrEvent } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt index 89bbe6b19..c8a3e25dd 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt @@ -23,11 +23,13 @@ fun NavController.navigateToScanQr(navOptions: NavOptions? = null) = fun NavGraphBuilder.scanQrScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, ) { composableWithSlideTransitions(route = SCAN_QR_ROUTE) { ScanQrCodeScreen( navigateBack = navigateBack, navigateToSendScreen = navigateToSendScreen, + navigateToPayeeDetailsScreen = navigateToPayeeDetailsScreen, ) } } diff --git a/feature/send-money/build.gradle.kts b/feature/send-money/build.gradle.kts index 90df32a6d..f4e93408c 100644 --- a/feature/send-money/build.gradle.kts +++ b/feature/send-money/build.gradle.kts @@ -24,10 +24,16 @@ kotlin { implementation(compose.materialIconsExtended) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + implementation(projects.core.designsystem) } androidMain.dependencies { implementation(libs.google.play.services.code.scanner) + implementation(libs.accompanist.permissions) } } +} + +dependencies { + debugImplementation(compose.uiTooling) } \ No newline at end of file diff --git a/feature/send-money/src/androidMain/AndroidManifest.xml b/feature/send-money/src/androidMain/AndroidManifest.xml index ae124d8f0..b668135fa 100644 --- a/feature/send-money/src/androidMain/AndroidManifest.xml +++ b/feature/send-money/src/androidMain/AndroidManifest.xml @@ -8,4 +8,6 @@ See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md --> - + + + diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactPermissionHandler.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactPermissionHandler.android.kt new file mode 100644 index 000000000..c9da4f3b4 --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactPermissionHandler.android.kt @@ -0,0 +1,25 @@ +/* + * 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.runtime.Composable +import org.mifospay.core.designsystem.component.PermissionBox + +@Composable +actual fun ContactPermissionHandler() { + PermissionBox( + title = "Contact Permission Required", + confirmButtonText = "Grant Permission", + dismissButtonText = "Open Settings", + requiredPermissions = listOf("android.permission.READ_CONTACTS"), + description = "To help you select contacts for payments, we need access to your contacts. This allows you to quickly find and select contacts when making payments.", + onGranted = { }, + ) +} diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.android.kt new file mode 100644 index 000000000..ca9c6392d --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.android.kt @@ -0,0 +1,63 @@ +/* + * 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 android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +actual fun rememberContactPermissionState(): ContactPermissionState { + val accPermissionState = rememberPermissionState(android.Manifest.permission.READ_CONTACTS) + + val context = LocalContext.current + val wrapper = remember(accPermissionState) { + AccompanistContactPermissionWrapper(accPermissionState, context) + } + + return wrapper +} + +@OptIn(ExperimentalPermissionsApi::class) +class AccompanistContactPermissionWrapper( + private val permissionState: PermissionState, + private val context: Context, +) : ContactPermissionState { + override val status: ContactPermissionStatus + get() = permissionState.status.toContactPermissionStatus() + + override fun requestContactPermission() { + permissionState.launchPermissionRequest() + } + + override fun goToSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${context.packageName}".toUri() + } + context.startActivity(intent) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +private fun PermissionStatus.toContactPermissionStatus(): ContactPermissionStatus { + return when (this) { + is PermissionStatus.Granted -> ContactPermissionStatus.Granted + is PermissionStatus.Denied -> ContactPermissionStatus.Denied + } +} diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactRepository.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactRepository.android.kt new file mode 100644 index 000000000..bdb5a37c6 --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContactRepository.android.kt @@ -0,0 +1,85 @@ +/* + * 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 android.content.ContentResolver +import android.provider.ContactsContract +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberContactRepository(): ContactRepository { + val context = LocalContext.current + return remember { AndroidContactRepository(context.contentResolver) } +} + +class AndroidContactRepository( + private val contentResolver: ContentResolver, +) : ContactRepository { + + override suspend fun getContacts(): List { + val rawContacts = queryContacts(null) + return PhoneNumberUtils.filterAndFormatContacts(rawContacts) + } + + override suspend fun searchContacts(query: String): List { + val normalizedQuery = PhoneNumberUtils.normalizeSearchQuery(query) + // Search by name (contains) and phone number (starts with normalized query) + val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? OR ${ContactsContract.CommonDataKinds.Phone.NUMBER} LIKE ?" + val selectionArgs = arrayOf("%$query%", "$normalizedQuery%") + val rawContacts = queryContacts(selection, selectionArgs) + return PhoneNumberUtils.filterAndFormatContacts(rawContacts) + } + + private fun queryContacts(selection: String? = null, selectionArgs: Array? = null): List { + val contacts = mutableListOf() + + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ) + + val sortOrder = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC" + + contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder, + )?.use { cursor -> + val idColumn = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + val nameColumn = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) + val numberColumn = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + + while (cursor.moveToNext()) { + val id = cursor.getString(idColumn) + val name = cursor.getString(nameColumn) ?: "Unknown" + val number = cursor.getString(numberColumn) ?: "" + + val contact = Contact( + id = id, + name = name, + phoneNumber = number, + upiId = null, + ) + + if (!contacts.any { it.name == name && it.phoneNumber == number }) { + contacts.add(contact) + } + } + } + + return contacts + } +} diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContextProvider.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContextProvider.android.kt new file mode 100644 index 000000000..24c824961 --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/ContextProvider.android.kt @@ -0,0 +1,32 @@ +/* + * 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 android.content.Context + +/** + * Android-specific context provider for sharing functionality + */ +object ContextProvider { + private var applicationContext: Context? = null + + /** + * Initialize the context provider with the application context + * This should be called from the Application class or MainActivity + */ + fun init(context: Context) { + applicationContext = context.applicationContext + } + + /** + * Get the application context + */ + fun getContext(): Context? = applicationContext +} 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/androidMain/kotlin/org/mifospay/feature/send/money/SharingHelper.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/SharingHelper.android.kt new file mode 100644 index 000000000..c012b4fc5 --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/SharingHelper.android.kt @@ -0,0 +1,110 @@ +/* + * 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 android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri + +/** + * Android-specific implementation for sharing invite messages via SMS and WhatsApp + */ +class AndroidSharingHelper(private val context: android.content.Context) : SharingHelper { + + /** + * Shares the invite message via the selected sharing option + * + * @param sharingOption The selected sharing method (SMS or WhatsApp) + * @param phoneNumber The target phone number + * @param message The invite message to share + */ + override fun shareInviteMessage( + sharingOption: SharingOption, + phoneNumber: String, + message: String, + ) { + when (sharingOption) { + SharingOption.SMS -> shareViaSms(phoneNumber, message) + SharingOption.WHATSAPP -> shareViaWhatsApp(phoneNumber, message) + } + } + + /** + * Shares the invite message via SMS using Android's SMS intent + */ + private fun shareViaSms(phoneNumber: String, message: String) { + try { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = "smsto:$phoneNumber".toUri() + putExtra("sms_body", message) + } + + if (intent.resolveActivity(context.packageManager) != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + val fallbackIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, message) + putExtra("address", phoneNumber) + } + fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(Intent.createChooser(fallbackIntent, "Send SMS")) + } + } catch (e: Exception) { + println("Error sharing via SMS: ${e.message}") + } + } + + /** + * Shares the invite message via WhatsApp using WhatsApp's intent + */ + private fun shareViaWhatsApp(phoneNumber: String, message: String) { + try { + val cleanPhoneNumber = phoneNumber.replace("+", "").replace(" ", "") + + val intent = Intent(Intent.ACTION_VIEW).apply { + data = + "https://api.whatsapp.com/send?phone=$cleanPhoneNumber&text=${Uri.encode(message)}".toUri() + } + + // Check if WhatsApp is installed + if (intent.resolveActivity(context.packageManager) != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + // Fallback to generic sharing if WhatsApp is not installed + val fallbackIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, message) + putExtra("address", phoneNumber) + } + fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(Intent.createChooser(fallbackIntent, "Share via")) + } + } catch (e: Exception) { + // Handle any exceptions (e.g., WhatsApp not installed) + println("Error sharing via WhatsApp: ${e.message}") + } + } +} + +/** + * Android-specific implementation of the factory function + */ +actual fun getSharingHelper(): SharingHelper { + val context = ContextProvider.getContext() + return if (context != null) { + AndroidSharingHelper(context) + } else { + DefaultSharingHelper + } +} diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64d..ccdfcdb74 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,14 @@ 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 \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/Contact.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/Contact.kt new file mode 100644 index 000000000..ea1e9f68f --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/Contact.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import kotlinx.serialization.Serializable + +enum class ContactType { + PERSON, + BUSINESS, +} + +@Serializable +data class Contact( + val id: String, + val name: String, + val phoneNumber: String, + val upiId: String? = null, + val type: ContactType = ContactType.PERSON, +) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactNotFoundDialog.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactNotFoundDialog.kt new file mode 100644 index 000000000..8bb43d70d --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactNotFoundDialog.kt @@ -0,0 +1,213 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.icon.MifosIcons +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun ContactNotFoundDialog( + showDialog: Boolean, + selectedOption: SharingOption, + onOptionSelected: (SharingOption) -> Unit, + onNotNowClick: () -> Unit, + onSetAsDefaultClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (showDialog) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + Text( + text = "Set preferred ways to share", + style = KptTheme.typography.headlineSmall, + color = KptTheme.colorScheme.onSurface, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = "Your choice will be recommended for future sharing of any contact.", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + SharingOptionItem( + option = SharingOption.WHATSAPP, + title = "WhatsApp", + subtitle = "Share via WhatsApp", + isSelected = selectedOption == SharingOption.WHATSAPP, + onClick = { onOptionSelected(SharingOption.WHATSAPP) }, + ) + + SharingOptionItem( + option = SharingOption.SMS, + title = "SMS", + subtitle = "Share via SMS", + isSelected = selectedOption == SharingOption.SMS, + onClick = { onOptionSelected(SharingOption.SMS) }, + ) + } + }, + confirmButton = { + MifosButton( + onClick = onSetAsDefaultClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + contentColor = KptTheme.colorScheme.onPrimary, + ), + ) { + Text( + text = "Set as default", + style = KptTheme.typography.labelLarge, + ) + } + }, + dismissButton = { + TextButton( + onClick = onNotNowClick, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Not now", + style = KptTheme.typography.labelLarge, + color = KptTheme.colorScheme.primary, + ) + } + }, + containerColor = KptTheme.colorScheme.surface, + shape = KptTheme.shapes.large, + ) + } +} + +@Composable +private fun SharingOptionItem( + option: SharingOption, + title: String, + subtitle: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + shape = KptTheme.shapes.small, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + KptTheme.colorScheme.primaryContainer + } else { + KptTheme.colorScheme.surface + }, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 2.dp else 0.dp, + ), + ) { + ListItem( + headlineContent = { + Text( + text = title, + style = KptTheme.typography.bodyLarge, + color = if (isSelected) { + KptTheme.colorScheme.onPrimaryContainer + } else { + KptTheme.colorScheme.onSurface + }, + ) + }, + supportingContent = { + Text( + text = subtitle, + style = KptTheme.typography.bodyMedium, + color = if (isSelected) { + KptTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + KptTheme.colorScheme.onSurface.copy(alpha = 0.7f) + }, + ) + }, + leadingContent = { + Icon( + imageVector = when (option) { + SharingOption.WHATSAPP -> MifosIcons.Share + SharingOption.SMS -> MifosIcons.Share + }, + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = if (isSelected) { + KptTheme.colorScheme.onPrimaryContainer + } else { + KptTheme.colorScheme.onSurface.copy(alpha = 0.7f) + }, + ) + }, + trailingContent = { + IconButton( + onClick = onClick, + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (isSelected) { + KptTheme.colorScheme.onPrimaryContainer + } else { + KptTheme.colorScheme.onSurface.copy(alpha = 0.7f) + }, + ), + ) { + Icon( + imageVector = if (isSelected) { + MifosIcons.RadioButtonChecked + } else { + MifosIcons.RadioButtonUnchecked + }, + contentDescription = if (isSelected) "Selected" else "Not selected", + modifier = Modifier.size(20.dp), + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + ), + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactNotFoundLoading.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactNotFoundLoading.kt new file mode 100644 index 000000000..df1b54b65 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactNotFoundLoading.kt @@ -0,0 +1,46 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun ContactNotFoundLoading( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + CircularProgressIndicator( + color = KptTheme.colorScheme.primary, + ) + + Text( + text = "Checking...", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.kt new file mode 100644 index 000000000..247816644 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.kt @@ -0,0 +1,26 @@ +/* + * 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.runtime.Composable + +interface ContactPermissionState { + val status: ContactPermissionStatus + fun requestContactPermission() + fun goToSettings() +} + +enum class ContactPermissionStatus { + Denied, + Granted, +} + +@Composable +expect fun rememberContactPermissionState(): ContactPermissionState diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactRepository.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactRepository.kt new file mode 100644 index 000000000..de2cd1e87 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactRepository.kt @@ -0,0 +1,20 @@ +/* + * 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.runtime.Composable + +interface ContactRepository { + suspend fun getContacts(): List + suspend fun searchContacts(query: String): List +} + +@Composable +expect fun rememberContactRepository(): ContactRepository diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.kt new file mode 100644 index 000000000..5b3525aff --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.kt @@ -0,0 +1,263 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.OutlinedCard +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +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.AvatarBox +import template.core.base.designsystem.theme.KptTheme + +@Composable +expect fun ContactPermissionHandler() + +@Composable +fun ContactsPickerScreen( + onBackClick: () -> Unit, + onContactSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var searchQuery by remember { mutableStateOf("") } + var contacts by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + val contactPermissionState = rememberContactPermissionState() + val contactRepository = rememberContactRepository() + + LaunchedEffect(contactPermissionState.status) { + when (contactPermissionState.status) { + ContactPermissionStatus.Granted -> { + isLoading = true + try { + contacts = contactRepository.getContacts() + } catch (e: Exception) { + contacts = emptyList() + } finally { + isLoading = false + } + } + ContactPermissionStatus.Denied -> { + isLoading = false + } + } + } + + LaunchedEffect(searchQuery) { + if (contactPermissionState.status == ContactPermissionStatus.Granted && searchQuery.isNotEmpty()) { + try { + contacts = contactRepository.searchContacts(searchQuery) + } catch (e: Exception) { + } + } else if (contactPermissionState.status == ContactPermissionStatus.Granted && searchQuery.isEmpty()) { + try { + contacts = contactRepository.getContacts() + } catch (e: Exception) { + } + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = "Select Contact", + backPress = onBackClick, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + MifosOutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = "", + placeholder = { + Text( + text = "Search contacts...", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Search, + contentDescription = "Search", + modifier = Modifier.size(20.dp), + tint = KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search, + ), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + Text( + text = "All Contacts", + style = KptTheme.typography.labelLarge, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.8f), + textAlign = TextAlign.Left, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Loading contacts...", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } else if (contacts.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (searchQuery.isEmpty()) "No mobile contacts found" else "No mobile contacts match your search", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + items( + items = contacts, + key = { contact -> "${contact.id}_${contact.phoneNumber}" }, + ) { contact -> + ContactItem( + contact = contact, + onClick = { + println("ContactsPickerScreen: Contact clicked - ${contact.phoneNumber}") + onContactSelected(contact.phoneNumber) + }, + ) + + if (contact != contacts.last()) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = KptTheme.spacing.xs), + ) + } + } + } + } + } + } + } + + ContactPermissionHandler() +} + +@Composable +private fun ContactItem( + contact: Contact, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.small, + colors = CardDefaults.outlinedCardColors( + containerColor = KptTheme.colorScheme.surface, + ), + ) { + ListItem( + headlineContent = { + Text( + text = contact.name, + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurface, + ) + }, + supportingContent = { + Text( + text = contact.phoneNumber, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + }, + leadingContent = { + AvatarBox( + icon = MifosIcons.Person, + backgroundColor = KptTheme.colorScheme.primaryContainer, + contentColor = KptTheme.colorScheme.onPrimaryContainer, + ) + }, + colors = ListItemDefaults.colors( + containerColor = KptTheme.colorScheme.surface, + ), + ) + } +} + +@Preview +@Composable +fun ContactsPickerScreenPreview() { + ContactsPickerScreen( + onBackClick = {}, + onContactSelected = {}, + ) +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayAnyoneScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayAnyoneScreen.kt new file mode 100644 index 000000000..246f9ff11 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayAnyoneScreen.kt @@ -0,0 +1,804 @@ +/* + * 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.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +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.AvatarBox +import template.core.base.designsystem.theme.KptTheme + +// TODO replace dummy data after backend APIs are finalized +@Composable +fun PayAnyoneScreen( + onBackClick: () -> Unit, + onContactPickerClick: () -> Unit, + onContactSelected: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val viewModel: PayAnyoneViewModel = koinViewModel() + val state by viewModel.stateFlow.collectAsState() + + var currentPlaceholderIndex by remember { mutableStateOf(0) } + + val placeholderMessages = listOf( + "Enter UPI ID or number", + "Enter phone number or name", + ) + + val currentPlaceholder = placeholderMessages[currentPlaceholderIndex] + + val keyboardType = if (state.isKeyboardNumeric) { + KeyboardType.Number + } else { + KeyboardType.Text + } + + val keyboardToggleText = if (state.isKeyboardNumeric) "ABC" else "123" + + LaunchedEffect(Unit) { + while (true) { + delay(3000) + currentPlaceholderIndex = (currentPlaceholderIndex + 1) % placeholderMessages.size + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = "Pay Anyone", + backPress = onBackClick, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + Text( + text = "Pay any UPI app using name, number or UPI ID", + style = KptTheme.typography.titleSmall, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + MifosOutlinedTextField( + value = state.inputValue, + onValueChange = { + viewModel.trySendAction(PayAnyoneAction.InputValueChanged(it)) + }, + label = "", + placeholder = { + AnimatedContent( + targetState = currentPlaceholder, + transitionSpec = { + slideInVertically( + animationSpec = tween(500), + initialOffsetY = { fullHeight -> fullHeight }, + ) + fadeIn(animationSpec = tween(500)) togetherWith + slideOutVertically( + animationSpec = tween(500), + targetOffsetY = { fullHeight -> -fullHeight }, + ) + fadeOut(animationSpec = tween(500)) + }, + ) { placeholder -> + Text( + text = placeholder, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Search, + ), + trailingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = state.showClearIcon, + transitionSpec = { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(200)) + }, + ) { showClear -> + if (showClear) { + IconButton( + onClick = { + viewModel.trySendAction(PayAnyoneAction.ClearInput) + }, + colors = IconButtonDefaults.iconButtonColors( + contentColor = KptTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ), + ) { + Icon( + imageVector = MifosIcons.Close, + contentDescription = "Clear input", + modifier = Modifier.size(20.dp), + ) + } + } else { + Row { + IconButton( + onClick = { + viewModel.trySendAction(PayAnyoneAction.ToggleKeyboardType) + }, + colors = IconButtonDefaults.iconButtonColors( + contentColor = KptTheme.colorScheme.primary, + ), + ) { + Text( + text = keyboardToggleText, + style = KptTheme.typography.labelMedium, + color = KptTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(KptTheme.spacing.xs)) + + IconButton( + onClick = onContactPickerClick, + colors = IconButtonDefaults.iconButtonColors( + contentColor = KptTheme.colorScheme.primary, + ), + ) { + Icon( + imageVector = MifosIcons.Contact, + contentDescription = "Select from contacts", + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + }, + ) + } + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + if (state.showUpiHandleSuggestions) { + UpiHandleSuggestions( + onHandleSelected = { handle -> + viewModel.trySendAction(PayAnyoneAction.UpiHandleSelected(handle)) + }, + ) + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + + if (state.isSearching) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = KptTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + Text( + text = "Searching...", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + + if (state.showPartialNumberNote) { + PartialNumberNote() + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + + if (state.showContactNotFoundLoading) { + ContactNotFoundLoading() + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + + if (state.showContactNotFoundMessage) { + ContactNotFoundMessage() + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + + if (state.inputValue.isNotEmpty()) { + SearchResultsContent( + searchResults = state.searchResults, + onContactSelected = { contact -> + viewModel.trySendAction(PayAnyoneAction.ContactSelected(contact)) + onContactSelected(contact.phoneNumber) + }, + ) + } else { + DefaultContent( + recentContacts = state.recentContacts, + allContacts = state.allContacts, + onContactSelected = { contact -> + viewModel.trySendAction(PayAnyoneAction.ContactSelected(contact)) + onContactSelected(contact.phoneNumber) + }, + ) + } + } + } + } + + // Contact Not Found Dialog + ContactNotFoundDialog( + showDialog = state.showContactNotFoundDialog, + selectedOption = state.selectedSharingOption, + onOptionSelected = { option -> + viewModel.trySendAction(PayAnyoneAction.SharingOptionSelected(option)) + }, + onNotNowClick = { + viewModel.trySendAction(PayAnyoneAction.NotNowSharingOption) + }, + onSetAsDefaultClick = { + viewModel.trySendAction(PayAnyoneAction.SetAsDefaultSharingOption) + }, + onDismiss = { + viewModel.trySendAction(PayAnyoneAction.HideContactNotFoundDialog) + }, + ) +} + +@Composable +private fun SearchResultsContent( + searchResults: ContactSearchResult, + onContactSelected: (Contact) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + if (searchResults.people.isNotEmpty()) { + item { + SearchSection( + title = "People", + contacts = searchResults.people, + onContactSelected = onContactSelected, + ) + } + } + + if (searchResults.businesses.isNotEmpty()) { + item { + SearchSection( + title = "Businesses", + contacts = searchResults.businesses, + onContactSelected = onContactSelected, + ) + } + } + + if (searchResults.others.isNotEmpty()) { + item { + SearchSection( + title = "Others", + contacts = searchResults.others, + onContactSelected = onContactSelected, + ) + } + } + + if (searchResults.people.isEmpty() && searchResults.businesses.isEmpty() && searchResults.others.isEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + Text( + text = "No results found", + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = "Try searching with a different term", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } + } + } + } +} + +@Composable +private fun SearchSection( + title: String, + contacts: List, + onContactSelected: (Contact) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = title, + style = KptTheme.typography.titleMedium, + color = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + contacts.forEach { contact -> + SearchContactCard( + contact = contact, + onClick = { onContactSelected(contact) }, + ) + } + } + } +} + +@Composable +private fun SearchContactCard( + contact: Contact, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.small, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + AvatarBox( + name = contact.name, + size = 48, + backgroundColor = if (contact.type == ContactType.BUSINESS) { + KptTheme.colorScheme.secondaryContainer + } else { + KptTheme.colorScheme.primaryContainer + }, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = contact.name, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = contact.phoneNumber, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (contact.upiId != null) { + Text( + text = contact.upiId, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun DefaultContent( + recentContacts: List, + allContacts: List, + onContactSelected: (Contact) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + if (recentContacts.isNotEmpty()) { + item { + RecentContactsSection( + recentContacts = recentContacts, + onContactSelected = onContactSelected, + ) + } + } +// TODO Map device contact phone numbers to UPI IDs after backend specifications are finalized + if (allContacts.isNotEmpty()) { + item { + Text( + text = "All People on UPI", + style = KptTheme.typography.titleMedium, + color = KptTheme.colorScheme.onSurface, + ) + } + + item { + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + } + + items( + items = allContacts, + key = { contact -> "all_${contact.id}_${contact.phoneNumber}" }, + ) { contact -> + AllPeopleContactItem( + contact = contact, + onClick = { + onContactSelected(contact) + }, + ) + } + } + } +} + +@Composable +private fun RecentContactsSection( + recentContacts: List, + onContactSelected: (Contact) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = "Recent", + style = KptTheme.typography.titleMedium, + color = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + recentContacts.forEach { contact -> + RecentContactCard( + contact = contact, + onClick = { onContactSelected(contact) }, + ) + } + } + } +} + +@Composable +private fun RecentContactCard( + contact: Contact, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.small, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + AvatarBox( + name = contact.name, + size = 48, + backgroundColor = KptTheme.colorScheme.primaryContainer, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = contact.name, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (contact.upiId != null) { + Text( + text = contact.upiId, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun AllPeopleContactItem( + contact: Contact, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.small, + colors = CardDefaults.outlinedCardColors( + containerColor = KptTheme.colorScheme.surface, + ), + ) { + ListItem( + headlineContent = { + Text( + text = contact.name, + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurface, + ) + }, + supportingContent = { + Column { + Text( + text = contact.phoneNumber, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + if (contact.upiId != null) { + Text( + text = contact.upiId, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.primary, + ) + } + } + }, + leadingContent = { + AvatarBox( + name = contact.name, + backgroundColor = KptTheme.colorScheme.primaryContainer, + ) + }, + colors = ListItemDefaults.colors( + containerColor = KptTheme.colorScheme.surface, + ), + ) + } +} + +@Composable +private fun UpiHandleSuggestions( + onHandleSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = "Popular UPI handles", + style = KptTheme.typography.titleSmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = KptTheme.spacing.xs), + ) { + items(PayAnyoneViewModel.UPI_HANDLES) { handle -> + UpiHandleChip( + handle = handle, + onClick = { onHandleSelected(handle) }, + ) + } + } + } +} + +@Composable +private fun UpiHandleChip( + handle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .clickable { onClick() } + .padding(vertical = 2.dp), + shape = KptTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + ) { + Text( + text = handle, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + ) + } +} + +@Composable +private fun PartialNumberNote( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = KptTheme.shapes.small, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer.copy(alpha = 0.1f), + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = "Information", + modifier = Modifier.size(16.dp), + tint = KptTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + Text( + text = "To find more people on any UPI app, enter full number", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun ContactNotFoundMessage( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = KptTheme.shapes.small, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.errorContainer.copy(alpha = 0.1f), + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = "Information", + modifier = Modifier.size(16.dp), + tint = KptTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + Text( + text = "Could not find user. Redirecting you to invite them on Google Play", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + } +} + +@Preview +@Composable +fun PayAnyoneScreenPreview() { + PayAnyoneScreen( + onBackClick = {}, + onContactPickerClick = {}, + onContactSelected = {}, + ) +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayAnyoneViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayAnyoneViewModel.kt new file mode 100644 index 000000000..75c3d004b --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayAnyoneViewModel.kt @@ -0,0 +1,618 @@ +/* + * 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.ui.utils.BaseViewModel + +class PayAnyoneViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: PayAnyoneState(), +) { + + companion object { + private const val KEY_STATE = "pay_anyone_state" + private const val SEARCH_DEBOUNCE_DELAY = 300L + private const val MIN_SEARCH_LENGTH = 1 + + val UPI_HANDLES = listOf( + "@ybl", "@axl", "@ptsbi", "@oksbi", "@pthdfc", "@icici", "@paytm", "@phonepe", "@upi", "@okicici", + "@kotak", "@hdfc", "@sbi", "@axis", "@unionbank", "@canara", "@pnb", "@yesbank", + ) + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + // Handle selected contact phone from navigation arguments + val selectedContactPhone = savedStateHandle.get("selectedContact") + if (selectedContactPhone != null) { + mutableStateFlow.update { + it.copy( + inputValue = selectedContactPhone, + showClearIcon = true, + isSearching = selectedContactPhone.length >= MIN_SEARCH_LENGTH, + showPartialNumberNote = false, + searchResults = if (selectedContactPhone.isEmpty()) ContactSearchResult() else it.searchResults, + ) + } + + // Trigger search if the phone number meets minimum length + if (selectedContactPhone.length >= MIN_SEARCH_LENGTH) { + viewModelScope.launch { + val searchResult = searchContacts(selectedContactPhone) + val isPartialNumber = isPartialPhoneNumber(selectedContactPhone) + val hasNoResults = searchResult.people.isEmpty() && searchResult.others.isEmpty() && searchResult.businesses.isEmpty() + + mutableStateFlow.update { + it.copy( + isSearching = false, + searchResults = searchResult, + showPartialNumberNote = isPartialNumber && hasNoResults, + ) + } + } + } + } + + // Setup search functionality + setupSearchFlow() + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + private fun setupSearchFlow() { + stateFlow + .map { it.inputValue } + .distinctUntilChanged() + .debounce(SEARCH_DEBOUNCE_DELAY) + .filter { it.length >= MIN_SEARCH_LENGTH } + .flatMapLatest { searchQuery -> + performSearch(searchQuery) + } + .launchIn(viewModelScope) + } + + private fun performSearch(query: String): Flow { + return kotlinx.coroutines.flow.flow { + // Show loading state + mutableStateFlow.update { it.copy(isSearching = true) } + + // Simulate network delay + delay(500) + + val searchResult = searchContacts(query) + val isPartialNumber = isPartialPhoneNumber(query) + val hasNoResults = searchResult.people.isEmpty() && searchResult.others.isEmpty() && searchResult.businesses.isEmpty() + + // Update state with search results + mutableStateFlow.update { + it.copy( + isSearching = false, + searchResults = searchResult, + showPartialNumberNote = isPartialNumber && hasNoResults, + ) + } + + // Note: Phone numbers not found on UPI will now appear in the "Others" section + // instead of triggering the contact not found dialog + + emit(searchResult) + } + } + + private fun searchContacts(query: String): ContactSearchResult { + val allContacts = sampleAllContacts + sampleRecentContacts + sampleBusinessContacts + val normalizedQuery = query.lowercase().trim() + + val filteredContacts = allContacts.filter { contact -> + contact.name.lowercase().contains(normalizedQuery) || + contact.phoneNumber.replace(" ", "").startsWith(normalizedQuery.replace(" ", "")) || + (contact.upiId?.lowercase()?.contains(normalizedQuery) == true) + } + + // If no contacts found but query looks like a phone number, create a contact for "Others" section + val contactsToShow = if (filteredContacts.isEmpty() && isFullPhoneNumber(query)) { + listOf( + Contact( + id = "not_found_${query.hashCode()}", + name = "Unknown Contact", + phoneNumber = query, + upiId = null, + type = ContactType.PERSON, + ), + ) + } else { + filteredContacts + } + + val people = contactsToShow.filter { it.upiId != null && it.type == ContactType.PERSON } + val others = contactsToShow.filter { it.upiId == null && it.type == ContactType.PERSON } + val businesses = contactsToShow.filter { it.type == ContactType.BUSINESS } + + return ContactSearchResult( + people = people, + others = others, + businesses = businesses, + ) + } + + private fun isPartialPhoneNumber(query: String): Boolean { + val cleanQuery = query.replace(" ", "").replace("+", "").replace("-", "") + return cleanQuery.length in 1..9 && cleanQuery.all { it.isDigit() } + } + + private fun isFullPhoneNumber(query: String): Boolean { + val cleanQuery = query.replace(" ", "").replace("+", "").replace("-", "") + return cleanQuery.length >= 10 && cleanQuery.all { it.isDigit() } + } + + private fun shareInviteMessage(sharingOption: SharingOption, phoneNumber: String) { + val inviteMessage = createInviteMessage(phoneNumber) + + // Use the platform-specific sharing helper + val sharingHelper = getSharingHelper() + sharingHelper.shareInviteMessage(sharingOption, phoneNumber, inviteMessage) + } + + private fun createInviteMessage(phoneNumber: String): String { + return buildString { + appendLine("Hi! I'm trying to send you money via UPI, but I couldn't find you on any UPI app.") + appendLine() + appendLine("Please download Mifos Pay to receive payments instantly:") + appendLine("https://play.google.com/store/apps/details?id=org.mifospay&hl=en_IN") + appendLine() + appendLine("Once you install the app, I'll be able to send you money directly!") + appendLine() + appendLine("Thanks!") + } + } + + private fun showContactNotFoundFlow(phoneNumber: String) { + viewModelScope.launch { + // Show loading state + mutableStateFlow.update { + it.copy( + showContactNotFoundLoading = true, + showContactNotFoundMessage = false, + showContactNotFoundDialog = false, + inputValue = phoneNumber, + ) + } + + // Simulate delay + delay(2000) + + // Show message + mutableStateFlow.update { + it.copy( + showContactNotFoundLoading = false, + showContactNotFoundMessage = true, + showContactNotFoundDialog = false, + ) + } + + // Show dialog after another delay + delay(1500) + + mutableStateFlow.update { + it.copy( + showContactNotFoundLoading = false, + showContactNotFoundMessage = false, + showContactNotFoundDialog = true, + ) + } + } + } + + /** + * Determines if UPI handle suggestions should be shown based on user input + * Shows suggestions when user types '@' followed by 0 or 1 character + */ + private fun shouldShowUpiHandleSuggestions(input: String): Boolean { + val lastAtSymbolIndex = input.lastIndexOf('@') + if (lastAtSymbolIndex == -1) return false + + val textAfterAt = input.substring(lastAtSymbolIndex + 1) + // Show suggestions if there's no text after @ or just 1 character + return textAfterAt.isEmpty() || (textAfterAt.length == 1 && !textAfterAt.contains(' ')) + } + + override fun handleAction(action: PayAnyoneAction) { + when (action) { + is PayAnyoneAction.InputValueChanged -> { + val newValue = action.value + val shouldShowSuggestions = shouldShowUpiHandleSuggestions(newValue) + + mutableStateFlow.update { + it.copy( + inputValue = newValue, + showClearIcon = newValue.isNotEmpty(), + isSearching = newValue.length >= MIN_SEARCH_LENGTH && !shouldShowSuggestions, + showPartialNumberNote = false, + showUpiHandleSuggestions = shouldShowSuggestions, + searchResults = if (newValue.isEmpty()) ContactSearchResult() else it.searchResults, + ) + } + } + + is PayAnyoneAction.PhoneNumberSelected -> { + val phoneNumber = action.phoneNumber + mutableStateFlow.update { + it.copy( + inputValue = phoneNumber, + showClearIcon = true, + isSearching = phoneNumber.length >= MIN_SEARCH_LENGTH, + showPartialNumberNote = false, + showUpiHandleSuggestions = false, + searchResults = if (phoneNumber.isEmpty()) ContactSearchResult() else it.searchResults, + ) + } + + // Trigger search if the phone number meets minimum length + if (phoneNumber.length >= MIN_SEARCH_LENGTH) { + viewModelScope.launch { + val searchResult = searchContacts(phoneNumber) + val isPartialNumber = isPartialPhoneNumber(phoneNumber) + val hasNoResults = searchResult.people.isEmpty() && searchResult.others.isEmpty() && searchResult.businesses.isEmpty() + + mutableStateFlow.update { + it.copy( + isSearching = false, + searchResults = searchResult, + showPartialNumberNote = isPartialNumber && hasNoResults, + ) + } + } + } + } + + is PayAnyoneAction.UpiHandleSelected -> { + val currentInput = stateFlow.value.inputValue + val lastAtSymbolIndex = currentInput.lastIndexOf('@') + val newValue = if (lastAtSymbolIndex != -1) { + // Replace everything from @ onwards with the selected handle + currentInput.substring(0, lastAtSymbolIndex) + action.handle + } else { + currentInput + action.handle + } + + mutableStateFlow.update { + it.copy( + inputValue = newValue, + showClearIcon = newValue.isNotEmpty(), + isSearching = false, + showPartialNumberNote = false, + showUpiHandleSuggestions = false, + searchResults = ContactSearchResult(), + ) + } + } + + PayAnyoneAction.ClearInput -> { + mutableStateFlow.update { + it.copy( + inputValue = "", + showClearIcon = false, + isSearching = false, + showPartialNumberNote = false, + showUpiHandleSuggestions = false, + searchResults = ContactSearchResult(), + ) + } + } + + PayAnyoneAction.ToggleKeyboardType -> { + mutableStateFlow.update { + it.copy( + isKeyboardNumeric = !it.isKeyboardNumeric, + ) + } + } + + PayAnyoneAction.ShowContactNotFoundDialog -> { + mutableStateFlow.update { + it.copy( + showContactNotFoundDialog = true, + showContactNotFoundMessage = true, + ) + } + } + + PayAnyoneAction.HideContactNotFoundDialog -> { + mutableStateFlow.update { + it.copy( + showContactNotFoundDialog = false, + ) + } + } + + is PayAnyoneAction.SharingOptionSelected -> { + mutableStateFlow.update { + it.copy( + selectedSharingOption = action.option, + ) + } + } + + PayAnyoneAction.SetAsDefaultSharingOption -> { + // Save the selected sharing option as default + val selectedOption = stateFlow.value.selectedSharingOption + shareInviteMessage(selectedOption, stateFlow.value.inputValue) + mutableStateFlow.update { + it.copy( + showContactNotFoundDialog = false, + defaultSharingOption = selectedOption, + ) + } + } + + PayAnyoneAction.NotNowSharingOption -> { + // Share the invite message without setting as default + shareInviteMessage(stateFlow.value.selectedSharingOption, stateFlow.value.inputValue) + mutableStateFlow.update { + it.copy( + showContactNotFoundDialog = false, + ) + } + } + + PayAnyoneAction.ShareInviteMessage -> { + shareInviteMessage(stateFlow.value.selectedSharingOption, stateFlow.value.inputValue) + } + + is PayAnyoneAction.ContactSelected -> { + val contact = action.contact + + if (contact.upiId == null) { + val defaultOption = stateFlow.value.defaultSharingOption + + if (defaultOption != null) { + shareInviteMessage(defaultOption, contact.phoneNumber) + } else { + showContactNotFoundFlow(contact.phoneNumber) + } + } + } + } + } +} + +@Serializable +data class PayAnyoneState( + val inputValue: String = "", + val isKeyboardNumeric: Boolean = false, + val showClearIcon: Boolean = false, + val isSearching: Boolean = false, + val showPartialNumberNote: Boolean = false, + val showUpiHandleSuggestions: Boolean = false, + val recentContacts: List = sampleRecentContacts, + val allContacts: List = sampleAllContacts, + val searchResults: ContactSearchResult = ContactSearchResult(), + val showContactNotFoundDialog: Boolean = false, + val showContactNotFoundMessage: Boolean = false, + val showContactNotFoundLoading: Boolean = false, + val selectedSharingOption: SharingOption = SharingOption.WHATSAPP, + val defaultSharingOption: SharingOption? = null, + val inviteMessage: String = "", +) + +@Serializable +enum class SharingOption { + WHATSAPP, + SMS, +} + +@Serializable +data class ContactSearchResult( + val people: List = emptyList(), + val others: List = emptyList(), + val businesses: List = emptyList(), +) + +private val sampleRecentContacts = listOf( + Contact( + id = "1", + name = "John Doe", + phoneNumber = "+91 98765 43210", + upiId = "john.doe@upi", + type = ContactType.PERSON, + ), + Contact( + id = "2", + name = "Jane Smith", + phoneNumber = "+91 98765 43211", + upiId = "jane.smith@okicici", + type = ContactType.PERSON, + ), + Contact( + id = "3", + name = "Mike Johnson", + phoneNumber = "+91 98765 43212", + upiId = "mike.johnson@paytm", + type = ContactType.PERSON, + ), + Contact( + id = "4", + name = "Sarah Wilson", + phoneNumber = "+91 98765 43213", + upiId = "sarah.wilson@phonepe", + type = ContactType.PERSON, + ), +) + +private val sampleAllContacts = listOf( + Contact( + id = "5", + name = "Alice Brown", + phoneNumber = "+91 98765 43214", + upiId = "alice.brown@upi", + type = ContactType.PERSON, + ), + Contact( + id = "6", + name = "Bob Davis", + phoneNumber = "+91 98765 43215", + upiId = "bob.davis@okicici", + type = ContactType.PERSON, + ), + Contact( + id = "7", + name = "Carol Miller", + phoneNumber = "+91 98765 43216", + upiId = "carol.miller@paytm", + type = ContactType.PERSON, + ), + Contact( + id = "8", + name = "David Garcia", + phoneNumber = "+91 98765 43217", + upiId = "david.garcia@phonepe", + type = ContactType.PERSON, + ), + Contact( + id = "9", + name = "Emma Rodriguez", + phoneNumber = "+91 98765 43218", + upiId = "emma.rodriguez@upi", + type = ContactType.PERSON, + ), + Contact( + id = "10", + name = "Frank Martinez", + phoneNumber = "+91 98765 43219", + upiId = "frank.martinez@okicici", + type = ContactType.PERSON, + ), + Contact( + id = "11", + name = "Grace Lee", + phoneNumber = "+91 98765 43220", + upiId = "grace.lee@paytm", + type = ContactType.PERSON, + ), + Contact( + id = "12", + name = "Henry Taylor", + phoneNumber = "+91 98765 43221", + upiId = "henry.taylor@phonepe", + type = ContactType.PERSON, + ), + // Contacts without UPI ID (Others section) + Contact( + id = "13", + name = "Tom Wilson", + phoneNumber = "+91 98765 43222", + type = ContactType.PERSON, + ), + Contact( + id = "14", + name = "Lisa Anderson", + phoneNumber = "+91 98765 43223", + type = ContactType.PERSON, + ), + Contact( + id = "15", + name = "Mark Thompson", + phoneNumber = "+91 98765 43224", + type = ContactType.PERSON, + ), +) + +private val sampleBusinessContacts = listOf( + Contact( + id = "16", + name = "Amazon India", + phoneNumber = "+91 1800 3000 9009", + upiId = "amazon@icici", + type = ContactType.BUSINESS, + ), + Contact( + id = "17", + name = "Flipkart", + phoneNumber = "+91 1800 202 9898", + upiId = "flipkart@paytm", + type = ContactType.BUSINESS, + ), + Contact( + id = "18", + name = "Swiggy", + phoneNumber = "+91 1800 208 2088", + upiId = "swiggy@phonepe", + type = ContactType.BUSINESS, + ), + Contact( + id = "19", + name = "Zomato", + phoneNumber = "+91 1800 208 1222", + upiId = "zomato@upi", + type = ContactType.BUSINESS, + ), + Contact( + id = "20", + name = "Uber", + phoneNumber = "+91 1800 102 3837", + upiId = "uber@okicici", + type = ContactType.BUSINESS, + ), + Contact( + id = "21", + name = "Netflix", + phoneNumber = "+91 1800 102 1234", + upiId = "netflix@paytm", + type = ContactType.BUSINESS, + ), + Contact( + id = "22", + name = "Spotify", + phoneNumber = "+91 1800 102 5678", + upiId = "spotify@phonepe", + type = ContactType.BUSINESS, + ), +) + +sealed interface PayAnyoneEvent { + data object NavigateBack : PayAnyoneEvent + data object NavigateToContactPicker : PayAnyoneEvent +} + +sealed interface PayAnyoneAction { + data class InputValueChanged(val value: String) : PayAnyoneAction + data class PhoneNumberSelected(val phoneNumber: String) : PayAnyoneAction + data object ClearInput : PayAnyoneAction + data object ToggleKeyboardType : PayAnyoneAction + data class UpiHandleSelected(val handle: String) : PayAnyoneAction + data object ShowContactNotFoundDialog : PayAnyoneAction + data object HideContactNotFoundDialog : PayAnyoneAction + data class SharingOptionSelected(val option: SharingOption) : PayAnyoneAction + data object SetAsDefaultSharingOption : PayAnyoneAction + data object NotNowSharingOption : PayAnyoneAction + data object ShareInviteMessage : PayAnyoneAction + data class ContactSelected(val contact: Contact) : PayAnyoneAction +} 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/PhoneNumberUtils.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PhoneNumberUtils.kt new file mode 100644 index 000000000..b53de9893 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PhoneNumberUtils.kt @@ -0,0 +1,190 @@ +/* + * 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 + +object PhoneNumberUtils { + + /** + * Formats an Indian mobile number to the format +91 88888 88888 + */ + fun formatIndianMobileNumber(phoneNumber: String): String { + // Remove all non-digit characters + val digitsOnly = phoneNumber.replace(Regex("[^0-9]"), "") + + // Check if it's a valid Indian mobile number + if (!isValidIndianMobileNumber(digitsOnly)) { + return phoneNumber // Return original if not valid + } + + // Handle different formats + return when { + // If it starts with +91, format the remaining part + digitsOnly.startsWith("91") && digitsOnly.length == 12 -> { + val number = digitsOnly.substring(2) + "+91 ${number.substring(0, 5)} ${number.substring(5)}" + } + // If it's a 10-digit number, add +91 + digitsOnly.length == 10 -> { + "+91 ${digitsOnly.substring(0, 5)} ${digitsOnly.substring(5)}" + } + // If it's already 12 digits (with country code), format it + digitsOnly.length == 12 -> { + "+${digitsOnly.substring(0, 2)} ${digitsOnly.substring(2, 7)} ${digitsOnly.substring(7)}" + } + else -> phoneNumber // Return original if format is not recognized + } + } + + /** + * Checks if a phone number is a valid Indian mobile number + */ + fun isValidIndianMobileNumber(phoneNumber: String): Boolean { + // Remove all non-digit characters + val digitsOnly = phoneNumber.replace(Regex("[^0-9]"), "") + + // Check for common non-mobile patterns + if (isNonMobileNumber(digitsOnly)) { + return false + } + + // Valid Indian mobile number patterns: + // 1. 10 digits starting with 6, 7, 8, 9 + // 2. 12 digits starting with 91 followed by 6, 7, 8, 9 + return when { + digitsOnly.length == 10 -> { + digitsOnly.startsWith("6") || digitsOnly.startsWith("7") || + digitsOnly.startsWith("8") || digitsOnly.startsWith("9") + } + digitsOnly.length == 12 && digitsOnly.startsWith("91") -> { + val number = digitsOnly.substring(2) + number.startsWith("6") || number.startsWith("7") || + number.startsWith("8") || number.startsWith("9") + } + else -> false + } + } + + /** + * Checks if a phone number is a non-mobile number (landline, toll-free, etc.) + */ + private fun isNonMobileNumber(digitsOnly: String): Boolean { + // Remove country code if present for checking + val numberToCheck = if (digitsOnly.length == 12 && digitsOnly.startsWith("91")) { + digitsOnly.substring(2) + } else { + digitsOnly + } + + // Landline numbers (start with 0 or 1-5) + if (numberToCheck.startsWith("0") || + numberToCheck.startsWith("1") || + numberToCheck.startsWith("2") || + numberToCheck.startsWith("3") || + numberToCheck.startsWith("4") || + numberToCheck.startsWith("5") + ) { + return true + } + + // Toll-free numbers (1800, 1860, etc.) + if (numberToCheck.startsWith("1800") || + numberToCheck.startsWith("1860") || + numberToCheck.startsWith("1861") || + numberToCheck.startsWith("1862") || + numberToCheck.startsWith("1863") || + numberToCheck.startsWith("1864") || + numberToCheck.startsWith("1865") || + numberToCheck.startsWith("1866") || + numberToCheck.startsWith("1867") || + numberToCheck.startsWith("1868") || + numberToCheck.startsWith("1869") + ) { + return true + } + + // Special service numbers (100, 101, 102, etc.) + if (numberToCheck.startsWith("100") || + numberToCheck.startsWith("101") || + numberToCheck.startsWith("102") || + numberToCheck.startsWith("103") || + numberToCheck.startsWith("104") || + numberToCheck.startsWith("105") || + numberToCheck.startsWith("106") || + numberToCheck.startsWith("107") || + numberToCheck.startsWith("108") || + numberToCheck.startsWith("109") || + numberToCheck.startsWith("110") || + numberToCheck.startsWith("112") || + numberToCheck.startsWith("113") || + numberToCheck.startsWith("114") || + numberToCheck.startsWith("115") || + numberToCheck.startsWith("116") || + numberToCheck.startsWith("117") || + numberToCheck.startsWith("118") || + numberToCheck.startsWith("119") + ) { + return true + } + + // USSD codes (*123#, etc.) + if (numberToCheck.contains("*") || numberToCheck.contains("#")) { + return true + } + + // Numbers that are too short or too long + if (numberToCheck.length < 10 || numberToCheck.length > 10) { + return true + } + + return false + } + + /** + * Filters and formats a list of contacts to only include valid mobile numbers + */ + fun filterAndFormatContacts(contacts: List): List { + return contacts + .filter { contact -> isValidIndianMobileNumber(contact.phoneNumber) } + .map { contact -> + contact.copy( + phoneNumber = formatIndianMobileNumber(contact.phoneNumber), + ) + } + .distinctBy { contact -> contact.phoneNumber } + } + + /** + * Normalizes a phone number for search purposes by removing country code and spaces + * This allows searching with or without +91 prefix + */ + fun normalizePhoneNumberForSearch(phoneNumber: String): String { + return phoneNumber + .replace("+91", "") + .replace(" ", "") + .replace("-", "") + .replace("(", "") + .replace(")", "") + .trim() + } + + /** + * Normalizes a search query for phone number matching + * Removes +91 prefix and spaces to match against normalized phone numbers + */ + fun normalizeSearchQuery(query: String): String { + return query + .replace("+91", "") + .replace(" ", "") + .replace("-", "") + .replace("(", "") + .replace(")", "") + .trim() + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt new file mode 100644 index 000000000..aaa28a8c1 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,470 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_choose_method +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_fineract_payments +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_merchants +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_more +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_pay_anyone +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_people +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_scan_qr_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_send +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun SendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SendMoneyOptionsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + SendMoneyOptionsEvent.NavigateBack -> { + onBackClick.invoke() + } + SendMoneyOptionsEvent.NavigateToPayAnyone -> { + onPayAnyoneClick.invoke() + } + SendMoneyOptionsEvent.NavigateToBankTransfer -> { + onBankTransferClick.invoke() + } + SendMoneyOptionsEvent.NavigateToFineractPayments -> { + onFineractPaymentsClick.invoke() + } + is SendMoneyOptionsEvent.QrCodeScanned -> { + onQrCodeScanned.invoke(event.data) + } + is SendMoneyOptionsEvent.NavigateToPayeeDetails -> { + onNavigateToPayeeDetails.invoke(event.qrCodeData) + } + } + } + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_send), + backPress = { + viewModel.trySendAction(SendMoneyOptionsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyBanner() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + SendMoneyOptionsRow( + onScanQrClick = { + viewModel.trySendAction(SendMoneyOptionsAction.ScanQrClicked) + }, + onPayAnyoneClick = { + viewModel.trySendAction(SendMoneyOptionsAction.PayAnyoneClicked) + }, + onBankTransferClick = { + viewModel.trySendAction(SendMoneyOptionsAction.BankTransferClicked) + }, + onFineractPaymentsClick = { + viewModel.trySendAction(SendMoneyOptionsAction.FineractPaymentsClicked) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PeopleSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + MerchantsSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + } + } + } +} + +@Composable +private fun SendMoneyBanner( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.xl), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_send_money_choose_method), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } +} + +@Composable +private fun SendMoneyOptionsRow( + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.Scan, + label = stringResource(Res.string.feature_send_money_scan_qr_code), + onClick = onScanQrClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Person, + label = stringResource(Res.string.feature_send_money_pay_anyone), + onClick = onPayAnyoneClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Bank, + label = stringResource(Res.string.feature_send_money_bank_transfer), + onClick = onBankTransferClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Payment, + label = stringResource(Res.string.feature_send_money_fineract_payments), + onClick = onFineractPaymentsClick, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun SendMoneyOptionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { onClick() }, + color = KptTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(56.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(KptTheme.spacing.sm), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + text = label, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} + +@Composable +private fun PeopleSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. People functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_people), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "John Doe", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Jane Smith", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Mike Johnson", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Sarah Wilson", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "David Brown", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Lisa Davis", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Tom Miller", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun MerchantsSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. Merchants functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_merchants), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Coffee Shop", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Grocery Store", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Restaurant", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Gas Station", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Pharmacy", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bookstore", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bakery", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun PersonItem( + name: String, + isMoreButton: Boolean = false, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { /* TODO: Handle click */ } + .clip(RoundedCornerShape(KptTheme.spacing.sm)), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = if (isMoreButton) { + KptTheme.colorScheme.secondaryContainer + } else { + KptTheme.colorScheme.primaryContainer + }, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isMoreButton) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = name, + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.onSecondaryContainer, + ) + } else { + Text( + text = name.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Text( + text = name, + style = KptTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt new file mode 100644 index 000000000..0e82e041e --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BackgroundEvent +import org.mifospay.core.ui.utils.BaseViewModel + +class SendMoneyOptionsViewModel( + private val scanner: QrScanner, +) : BaseViewModel( + initialState = SendMoneyOptionsState(), +) { + + override fun handleAction(action: SendMoneyOptionsAction) { + when (action) { + is SendMoneyOptionsAction.NavigateBack -> { + sendEvent(SendMoneyOptionsEvent.NavigateBack) + } + is SendMoneyOptionsAction.ScanQrClicked -> { + // Use ML Kit QR scanner directly + scanner.startScanning().onEach { data -> + data?.let { result -> + // Check if it's a UPI QR code or regular QR code + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + // Navigate to payee details screen for UPI QR codes + sendEvent(SendMoneyOptionsEvent.NavigateToPayeeDetails(result)) + } else { + // For non-UPI QR codes, navigate to Fineract payment + sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + } + } + }.launchIn(viewModelScope) + } + is SendMoneyOptionsAction.PayAnyoneClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToPayAnyone) + } + is SendMoneyOptionsAction.BankTransferClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToBankTransfer) + } + is SendMoneyOptionsAction.FineractPaymentsClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToFineractPayments) + } + } + } +} + +data class SendMoneyOptionsState( + val isLoading: Boolean = false, +) + +sealed interface SendMoneyOptionsEvent { + data object NavigateBack : SendMoneyOptionsEvent + data object NavigateToPayAnyone : SendMoneyOptionsEvent + data object NavigateToBankTransfer : SendMoneyOptionsEvent + data object NavigateToFineractPayments : SendMoneyOptionsEvent + data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent, BackgroundEvent + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyOptionsEvent, BackgroundEvent +} + +sealed interface SendMoneyOptionsAction { + data object NavigateBack : SendMoneyOptionsAction + data object ScanQrClicked : SendMoneyOptionsAction + data object PayAnyoneClicked : SendMoneyOptionsAction + data object BankTransferClicked : SendMoneyOptionsAction + data object FineractPaymentsClicked : SendMoneyOptionsAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 66d7bebd2..cd49c6f32 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -92,6 +91,7 @@ import template.core.base.designsystem.theme.KptTheme fun SendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetails: (String) -> Unit, navigateToScanQrScreen: () -> Unit, showTopBar: Boolean = true, modifier: Modifier = Modifier, @@ -108,7 +108,16 @@ fun SendMoneyScreen( navigateToTransferScreen(event.data) } + is SendMoneyEvent.NavigateToPayeeDetails -> { + navigateToPayeeDetails(event.qrCodeData) + } + is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() + + is SendMoneyEvent.ShowToast -> { + // TODO: Implement toast message display + // For now, we'll just ignore it + } } } @@ -130,7 +139,6 @@ fun SendMoneyScreen( ) } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun SendMoneyScreen( state: SendMoneyState, diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index 3ee69208a..6a9e9bb73 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -25,23 +25,26 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.send_money.generated.resources.Res import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_account_cannot_be_empty import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_amount_cannot_be_empty import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_invalid_amount import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_but_found import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_data_missing +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_upi_qr_parsed_successfully import org.jetbrains.compose.resources.StringResource import org.mifospay.core.common.DataState import org.mifospay.core.common.getSerialized import org.mifospay.core.common.setSerialized import org.mifospay.core.data.repository.AccountRepository +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor import org.mifospay.core.model.search.AccountResult import org.mifospay.core.model.utils.PaymentQrData import org.mifospay.core.model.utils.toAccount +import org.mifospay.core.ui.utils.BackgroundEvent import org.mifospay.core.ui.utils.BaseViewModel import org.mifospay.feature.send.money.SendMoneyAction.HandleRequestData import org.mifospay.feature.send.money.SendMoneyState.DialogState.Error @@ -120,7 +123,11 @@ class SendMoneyViewModel( SendMoneyAction.OnClickScan -> { scanner.startScanning().onEach { data -> data?.let { result -> - sendAction(HandleRequestData(result)) + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + sendEvent(SendMoneyEvent.NavigateToPayeeDetails(result)) + } else { + sendAction(HandleRequestData(result)) + } } }.launchIn(viewModelScope) // Using Play Service Code Scanner until Qr Scan module is stable @@ -176,7 +183,16 @@ class SendMoneyViewModel( private fun handleRequestData(action: HandleRequestData) { viewModelScope.launch { try { - val requestData = UpiQrCodeProcessor.decodeUpiString(action.requestData) + val requestData = try { + UpiQrCodeProcessor.decodeUpiString(action.requestData) + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(action.requestData)) { + val standardData = StandardUpiQrCodeProcessor.parseUpiQrCode(action.requestData) + StandardUpiQrCodeProcessor.toPaymentQrData(standardData) + } else { + throw e + } + } mutableStateFlow.update { state -> state.copy( @@ -185,6 +201,8 @@ class SendMoneyViewModel( selectedAccount = requestData.toAccount(), ) } + + sendEvent(SendMoneyEvent.ShowToast(Res.string.feature_send_money_upi_qr_parsed_successfully)) } catch (e: Exception) { val errorState = if (action.requestData.isNotEmpty()) { Error.GenericResourceMessage( @@ -210,7 +228,7 @@ data class SendMoneyState( val amount: String = "", val accountNumber: String = "", val selectedAccount: AccountResult? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && @@ -229,19 +247,16 @@ data class SendMoneyState( amount = amount, ) - @Serializable sealed interface DialogState { - @Serializable + data object Loading : DialogState - @Serializable sealed interface Error : DialogState { - @Serializable - data class ResourceMessage(@Contextual val message: StringResource) : Error - @Serializable + data class ResourceMessage(val message: StringResource) : Error + data class GenericResourceMessage( - @Contextual val message: StringResource, + val message: StringResource, val args: List, ) : Error } @@ -260,6 +275,9 @@ sealed interface SendMoneyEvent { data object OnNavigateBack : SendMoneyEvent data class NavigateToTransferScreen(val data: String) : SendMoneyEvent data object NavigateToScanQrScreen : SendMoneyEvent + + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyEvent, BackgroundEvent + data class ShowToast(val message: StringResource) : SendMoneyEvent } sealed interface SendMoneyAction { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SharingHelper.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SharingHelper.kt new file mode 100644 index 000000000..9c29526ff --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SharingHelper.kt @@ -0,0 +1,46 @@ +/* + * 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 + +/** + * Common interface for sharing invite messages across platforms + */ +interface SharingHelper { + /** + * Shares the invite message via the selected sharing option + * + * @param sharingOption The selected sharing method (SMS or WhatsApp) + * @param phoneNumber The target phone number + * @param message The invite message to share + */ + fun shareInviteMessage( + sharingOption: SharingOption, + phoneNumber: String, + message: String, + ) +} + +/** + * Default implementation that does nothing (for platforms without sharing support) + */ +object DefaultSharingHelper : SharingHelper { + override fun shareInviteMessage( + sharingOption: SharingOption, + phoneNumber: String, + message: String, + ) { + println("Sharing not supported on this platform: $sharingOption to $phoneNumber") + } +} + +/** + * Factory function to get the appropriate sharing helper for the current platform + */ +expect fun getSharingHelper(): SharingHelper 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..c4acfa7ac 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,16 @@ 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.PayAnyoneViewModel +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) + viewModelOf(::PayAnyoneViewModel) } 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..43c874293 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,13 @@ 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.ContactsPickerScreen +import org.mifospay.feature.send.money.PayAnyoneScreen +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 +28,57 @@ 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}" + +const val PAY_ANYONE_ROUTE = "pay_anyone_route" +const val PAY_ANYONE_SELECTED_CONTACT_ARG = "selectedContact" +const val PAY_ANYONE_BASE_ROUTE = "$PAY_ANYONE_ROUTE?$PAY_ANYONE_SELECTED_CONTACT_ARG={$PAY_ANYONE_SELECTED_CONTACT_ARG}" +const val CONTACTS_PICKER_ROUTE = "contacts_picker_route" + +fun NavController.navigateToPayAnyoneScreen( + selectedContactPhone: String? = null, + navOptions: NavOptions? = null, +) { + val route = if (selectedContactPhone != null) { + "$PAY_ANYONE_ROUTE?$PAY_ANYONE_SELECTED_CONTACT_ARG=$selectedContactPhone" + } else { + PAY_ANYONE_ROUTE + } + navigate(route, navOptions) +} + +fun NavController.navigateToContactsPickerScreen( + navOptions: NavOptions? = null, +) = navigate(CONTACTS_PICKER_ROUTE, navOptions) + 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 +95,90 @@ fun NavGraphBuilder.sendMoneyScreen( onBackClick = onBackClick, navigateToTransferScreen = navigateToTransferScreen, navigateToScanQrScreen = navigateToScanQrScreen, + navigateToPayeeDetails = navigateToPayeeDetailsScreen, + ) + } +} + +fun NavGraphBuilder.sendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + ) + } +} + +fun NavGraphBuilder.payAnyoneScreen( + onBackClick: () -> Unit, + onContactPickerClick: () -> Unit, + onContactSelected: (String) -> Unit = {}, +) { + composableWithSlideTransitions( + route = PAY_ANYONE_BASE_ROUTE, + arguments = listOf( + navArgument(PAY_ANYONE_SELECTED_CONTACT_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { + PayAnyoneScreen( + onBackClick = onBackClick, + onContactPickerClick = onContactPickerClick, + onContactSelected = onContactSelected, + ) + } +} + +fun NavGraphBuilder.contactsPickerScreen( + onBackClick: () -> Unit, + onContactSelected: (String) -> Unit, +) { + composableWithSlideTransitions( + route = CONTACTS_PICKER_ROUTE, + ) { + ContactsPickerScreen( + onBackClick = onBackClick, + onContactSelected = onContactSelected, + ) + } +} + +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 +188,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/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.desktop.kt b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.desktop.kt new file mode 100644 index 000000000..c27bb88e9 --- /dev/null +++ b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.desktop.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberContactPermissionState(): ContactPermissionState { + TODO("Not yet implemented") +} diff --git a/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactRepository.desktop.kt b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactRepository.desktop.kt new file mode 100644 index 000000000..2059befdf --- /dev/null +++ b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactRepository.desktop.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberContactRepository(): ContactRepository { + return object : ContactRepository { + override suspend fun getContacts(): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + + override suspend fun searchContacts(query: String): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + } +} diff --git a/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.desktop.kt b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.desktop.kt new file mode 100644 index 000000000..8af18b45b --- /dev/null +++ b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.desktop.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun ContactPermissionHandler() { +} diff --git a/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/SharingHelper.desktop.kt b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/SharingHelper.desktop.kt new file mode 100644 index 000000000..3bb08fe35 --- /dev/null +++ b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/SharingHelper.desktop.kt @@ -0,0 +1,17 @@ +/* + * 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 + +/** + * Desktop-specific implementation of the factory function + */ +actual fun getSharingHelper(): SharingHelper { + return DefaultSharingHelper +} diff --git a/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.js.kt b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.js.kt new file mode 100644 index 000000000..c27bb88e9 --- /dev/null +++ b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.js.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberContactPermissionState(): ContactPermissionState { + TODO("Not yet implemented") +} diff --git a/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactRepository.js.kt b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactRepository.js.kt new file mode 100644 index 000000000..2059befdf --- /dev/null +++ b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactRepository.js.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberContactRepository(): ContactRepository { + return object : ContactRepository { + override suspend fun getContacts(): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + + override suspend fun searchContacts(query: String): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + } +} diff --git a/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.js.kt b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.js.kt new file mode 100644 index 000000000..8af18b45b --- /dev/null +++ b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.js.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun ContactPermissionHandler() { +} diff --git a/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/SharingHelper.js.kt b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/SharingHelper.js.kt new file mode 100644 index 000000000..fa4707254 --- /dev/null +++ b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/SharingHelper.js.kt @@ -0,0 +1,17 @@ +/* + * 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 + +/** + * Web-specific implementation of the factory function + */ +actual fun getSharingHelper(): SharingHelper { + return DefaultSharingHelper +} diff --git a/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactPermissionHandler.native.kt b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactPermissionHandler.native.kt new file mode 100644 index 000000000..e44e0b323 --- /dev/null +++ b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactPermissionHandler.native.kt @@ -0,0 +1,17 @@ +/* + * 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.runtime.Composable + +@Composable +actual fun ContactPermissionHandler() { + // TODO +} diff --git a/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.native.kt b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.native.kt new file mode 100644 index 000000000..72e83b8b3 --- /dev/null +++ b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.native.kt @@ -0,0 +1,27 @@ +/* + * 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.runtime.Composable + +@Composable +actual fun rememberContactPermissionState(): ContactPermissionState { + return object : ContactPermissionState { + override val status: ContactPermissionStatus = ContactPermissionStatus.Denied + + override fun requestContactPermission() { + // TODO + } + + override fun goToSettings() { + // TODO + } + } +} diff --git a/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactRepository.native.kt b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactRepository.native.kt new file mode 100644 index 000000000..1baabdac8 --- /dev/null +++ b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/ContactRepository.native.kt @@ -0,0 +1,25 @@ +/* + * 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.runtime.Composable + +@Composable +actual fun rememberContactRepository(): ContactRepository { + return object : ContactRepository { + override suspend fun getContacts(): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + + override suspend fun searchContacts(query: String): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + } +} diff --git a/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/SharingHelper.native.kt b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/SharingHelper.native.kt new file mode 100644 index 000000000..5ee895a76 --- /dev/null +++ b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/SharingHelper.native.kt @@ -0,0 +1,17 @@ +/* + * 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 + +/** + * iOS-specific implementation of the factory function + */ +actual fun getSharingHelper(): SharingHelper { + return DefaultSharingHelper +} diff --git a/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.wasmJs.kt b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.wasmJs.kt new file mode 100644 index 000000000..c27bb88e9 --- /dev/null +++ b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactPermissionState.wasmJs.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberContactPermissionState(): ContactPermissionState { + TODO("Not yet implemented") +} diff --git a/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactRepository.wasmJs.kt b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactRepository.wasmJs.kt new file mode 100644 index 000000000..2059befdf --- /dev/null +++ b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactRepository.wasmJs.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberContactRepository(): ContactRepository { + return object : ContactRepository { + override suspend fun getContacts(): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + + override suspend fun searchContacts(query: String): List { + return PhoneNumberUtils.filterAndFormatContacts(emptyList()) + } + } +} diff --git a/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.wasmJs.kt b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.wasmJs.kt new file mode 100644 index 000000000..8af18b45b --- /dev/null +++ b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/ContactsPickerScreen.wasmJs.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.runtime.Composable + +@Composable +actual fun ContactPermissionHandler() { +} diff --git a/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/SharingHelper.wasmJs.kt b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/SharingHelper.wasmJs.kt new file mode 100644 index 000000000..3d966d749 --- /dev/null +++ b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/SharingHelper.wasmJs.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +actual fun getSharingHelper(): SharingHelper { + TODO("Not yet implemented") +}