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 03a443a9d..774dd5a3c 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,10 +71,29 @@ import org.mifospay.feature.savedcards.createOrUpdate.addEditCardScreen 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.AmountUtils import org.mifospay.feature.send.money.SendMoneyScreen +import org.mifospay.feature.send.money.navigation.PAYMENT_SUCCESS_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.navigateToPayeeDetailsScreen +import org.mifospay.feature.send.money.navigation.navigateToPaymentChatHistoryScreen +import org.mifospay.feature.send.money.navigation.navigateToPaymentDetailsScreen +import org.mifospay.feature.send.money.navigation.navigateToPaymentProcessingScreen +import org.mifospay.feature.send.money.navigation.navigateToPaymentSuccessScreen +import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.navigateToUpiPinScreen +import org.mifospay.feature.send.money.navigation.navigateToUpiTransactionHistoryScreen +import org.mifospay.feature.send.money.navigation.payeeDetailsScreen +import org.mifospay.feature.send.money.navigation.paymentChatHistoryScreen +import org.mifospay.feature.send.money.navigation.paymentDetailsScreen +import org.mifospay.feature.send.money.navigation.paymentProcessingScreen +import org.mifospay.feature.send.money.navigation.paymentSuccessScreen +import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen +import org.mifospay.feature.send.money.navigation.upiPinScreen +import org.mifospay.feature.send.money.navigation.upiTransactionHistoryScreen import org.mifospay.feature.settings.navigation.settingsScreen import org.mifospay.feature.standing.instruction.StandingInstructionsScreen import org.mifospay.feature.standing.instruction.createOrUpdate.addEditSIScreen @@ -98,6 +117,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -162,7 +182,7 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, navigateToHistory = navController::navigateToHistory, @@ -283,12 +303,143 @@ internal fun MifosNavHost( navigateBack = navController::navigateUp, ) + sendMoneyOptionsScreen( + onBackClick = navController::popBackStack, + onScanQrClick = { + // This is now handled by the ViewModel using ML Kit scanner + }, + onPayAnyoneClick = { + // TODO: Navigate to Pay Anyone screen + }, + onBankTransferClick = { + // TODO: Navigate to Bank Transfer screen + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onQrCodeScanned = { qrData -> + navController.navigateToSendMoneyScreen( + requestData = qrData, + navOptions = navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { + inclusive = true + } + }, + ) + }, + onNavigateToPayeeDetails = { qrCodeData -> + navController.navigateToPayeeDetailsScreen(qrCodeData) + }, + onPaymentHistoryClick = { + navController.navigateToPaymentChatHistoryScreen() + }, + onUpiTransactionHistoryClick = { + navController.navigateToUpiTransactionHistoryScreen() + }, + ) + sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, + navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen, navigateToScanQrScreen = navController::navigateToScanQr, ) + paymentChatHistoryScreen( + onBackClick = navController::popBackStack, + onPaymentClick = { + navController.navigateToSendMoneyOptionsScreen() + }, + onTransactionClick = { transactionId -> + navController.navigateToPaymentDetailsScreen(transactionId) + }, + ) + + upiTransactionHistoryScreen( + onBackClick = navController::popBackStack, + ) + + paymentDetailsScreen( + onBackClick = navController::popBackStack, + onPayAgainClick = { + navController.navigateToSendMoneyOptionsScreen() + }, + onRetryClick = { + navController.popBackStack() + }, + onShareScreenshot = { + // Screenshot functionality is handled by the Android-specific PaymentDetailsScreen implementation + // The actual screenshot and sharing is done within the screen itself + // This callback is used by the Android-specific implementation to trigger the screenshot + }, + ) + + payeeDetailsScreen( + onBackClick = navController::popBackStack, + onNavigateToUpiPin = { state -> + navController.navigateToUpiPinScreen( + payeeName = state.payeeName, + amount = state.amount, + isUpiCode = state.isUpiCode, + bankName = state.selectedAccount?.bankName ?: "Bank", + accountNo = state.selectedAccount?.accountNumber ?: "1234567890123456", + refId = state.refId, + ) + }, + ) + + upiPinScreen( + onBackClick = navController::popBackStack, + onNavigateToPaymentProcessing = { payeeName, amount, isUpiCode -> + val amountInPaise = AmountUtils.rupeesToPaise(amount) + navController.navigateToPaymentProcessingScreen( + payeeName = payeeName, + amount = amountInPaise, + isUpiCode = isUpiCode, + ) + }, + ) + + paymentProcessingScreen( + onPaymentComplete = { payeeName, amount, upiName, transactionTimestamp -> + navController.navigateToPaymentSuccessScreen( + payeeName = payeeName, + amount = amount, + upiName = upiName, + transactionTimestamp = transactionTimestamp, + ) + }, + onPaymentFailed = { errorMessage -> + navController.popBackStack() + }, + ) + + paymentSuccessScreen( + onShareScreenshot = { + // Screenshot functionality is handled by the Android-specific PaymentSuccessScreen implementation + // The actual screenshot and sharing is done within the screen itself + // This callback is used by the Android-specific implementation to trigger the screenshot + }, + onDone = { + navController.navigate(HOME_ROUTE) { + popUpTo(HOME_ROUTE) { + inclusive = false + } + launchSingleTop = true + } + }, + onNavigateToSendMoneyOptions = { + navController.navigateToSendMoneyOptionsScreen( + navOptions { + popUpTo(PAYMENT_SUCCESS_ROUTE) { + inclusive = true + } + launchSingleTop = true + }, + ) + }, + ) + transferScreen( navigateBack = navController::popBackStack, onTransferSuccess = { @@ -326,6 +477,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..2a7b4cecb 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,8 @@ 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.filled.Send import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.AttachMoney @@ -22,13 +24,17 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CurrencyRupee import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.PhotoLibrary @@ -36,6 +42,7 @@ import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -57,6 +64,8 @@ import androidx.compose.material.icons.outlined.Wallet import androidx.compose.material.icons.rounded.AccountBalance import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Contacts import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Info @@ -85,6 +94,9 @@ object MifosIcons { val Visibility: ImageVector = Icons.Filled.Visibility val Check: ImageVector = Icons.Default.Check val KeyboardArrowDown: ImageVector = Icons.Default.KeyboardArrowDown + val KeyboardArrowUp: ImageVector = Icons.Default.KeyboardArrowUp + val DropDown: ImageVector = Icons.Default.ExpandMore + val DropUp: ImageVector = Icons.Default.ExpandLess val Home = Icons.Outlined.Home val HomeBoarder = Icons.Rounded.Home val Payment = Icons.Rounded.SwapHoriz @@ -129,4 +141,12 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + + val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward + + val CurrencyRupee = Icons.Filled.CurrencyRupee + val CheckCircle = Icons.Rounded.CheckCircle + val CheckRounded = Icons.Rounded.Check + + val Send = Icons.AutoMirrored.Filled.Send } 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/core/ui/src/androidMain/kotlin/org/mifospay/core/ui/utils/PaymentSuccessScreenshotUtils.kt b/core/ui/src/androidMain/kotlin/org/mifospay/core/ui/utils/PaymentSuccessScreenshotUtils.kt new file mode 100644 index 000000000..24ab392f0 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/mifospay/core/ui/utils/PaymentSuccessScreenshotUtils.kt @@ -0,0 +1,288 @@ +/* + * 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.ui.utils + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.view.ViewGroup +import androidx.core.view.drawToBitmap +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.ImageFormat +import io.github.vinceglb.filekit.compressImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +// TODO enhance content presentation image shared in the screenshot utils +/** + * Specialized screenshot utility for PaymentSuccessScreen. + * + * This utility is designed specifically for taking screenshots of the payment success screen + * while excluding the advertisement banner and action buttons. It captures only the content + * above the advertisement banner for clean, professional screenshots. + */ +object PaymentSuccessScreenshotUtils { + + private const val CONTENT_PADDING_DP = 48 + private const val EXTRA_BOTTOM_PADDING_DP = 16 + private const val BUTTON_AREA_HEIGHT_RATIO = 0.25f + private const val BANNER_START_RATIO = 0.60f + private const val TOP_PADDING_RATIO = 0.03f + private const val COMPRESSION_QUALITY = 90 + private const val MAX_WIDTH = 1080 + private const val MAX_HEIGHT = 1920 + private const val TEST_TAG_PAYMENT_SUCCESS_CONTENT = "payment-success-content" + + /** + * Provider function to retrieve the current [Activity]. + * This must be set before using screenshot functionality. + */ + private var activityProvider: () -> Activity = { + throw IllegalArgumentException( + "You need to implement the 'activityProvider' to provide the required Activity. " + + "Just make sure to set a valid activity using " + + "the 'setActivityProvider()' method.", + ) + } + + /** + * Sets the activity provider function to be used internally for context retrieval. + * + * This is required to initialize before calling any screenshot methods. + * + * @param provider A lambda that returns the current [Activity]. + */ + fun setActivityProvider(provider: () -> Activity) { + activityProvider = provider + } + + /** + * Takes a screenshot of the PaymentSuccessScreen with context details and immediately shares it. + * + * @param contextDetails Payment context details for better file naming + * @param onError Callback when screenshot or sharing fails + */ + suspend fun takePaymentSuccessScreenshotAndShare( + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + try { + val screenshotBytes = takePaymentSuccessScreenshotSync() + val fileName = contextDetails.generateFileName() + val shareFile = ShareFileModel( + mime = MimeType.IMAGE, + fileName = fileName, + bytes = screenshotBytes, + ) + + ShareUtils.shareFile(shareFile) + } catch (e: Exception) { + onError("Failed to share payment success screenshot: ${e.message}") + } + } + + /** + * Takes a screenshot of the PaymentSuccessScreen synchronously and returns the bytes. + * + * @return Byte array of the compressed screenshot + */ + private suspend fun takePaymentSuccessScreenshotSync(): ByteArray { + return withContext(Dispatchers.Main) { + val activity = activityProvider.invoke() + val rootView = activity.findViewById(android.R.id.content) + + val screenshot = capturePaymentSuccessScreenshot(rootView) + compressScreenshot(screenshot) + } + } + + /** + * Captures a screenshot of the PaymentSuccessScreen while excluding the advertisement banner and action buttons. + * + * @param rootView The root view to capture + * @return Bitmap of the screenshot with only content above the advertisement banner + */ + private fun capturePaymentSuccessScreenshot(rootView: ViewGroup): Bitmap { + val bitmap = rootView.drawToBitmap() + val contentArea = findContentArea(rootView) + + return if (contentArea != null && contentArea.width() > 0 && contentArea.height() > 0) { + captureContentAreaScreenshot(bitmap, contentArea, rootView.resources.displayMetrics.density) + } else { + captureFallbackScreenshot(bitmap) + } + } + + /** + * Captures screenshot using the identified content area with proper padding. + */ + private fun captureContentAreaScreenshot( + bitmap: Bitmap, + contentArea: android.graphics.Rect, + density: Float, + ): Bitmap { + val paddingPx = (CONTENT_PADDING_DP * density).toInt() + val extraBottomPaddingPx = (EXTRA_BOTTOM_PADDING_DP * density).toInt() + + val paddedLeft = maxOf(0, contentArea.left - paddingPx) + val paddedTop = maxOf(0, contentArea.top - paddingPx) + val paddedRight = minOf(bitmap.width, contentArea.right + paddingPx) + val paddedBottom = minOf(bitmap.height, contentArea.bottom + paddingPx + extraBottomPaddingPx) + + val paddedWidth = paddedRight - paddedLeft + val paddedHeight = paddedBottom - paddedTop + + val contentBitmap = Bitmap.createBitmap( + bitmap, + paddedLeft, + paddedTop, + paddedWidth, + paddedHeight, + ) + bitmap.recycle() + return contentBitmap + } + + /** + * Captures screenshot using fallback method when content area is not found. + */ + private fun captureFallbackScreenshot(bitmap: Bitmap): Bitmap { + val canvas = Canvas(bitmap) + val paint = Paint().apply { + color = android.graphics.Color.TRANSPARENT + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + val screenHeight = bitmap.height + val screenWidth = bitmap.width + + val buttonAreaHeight = (screenHeight * BUTTON_AREA_HEIGHT_RATIO).toInt() + val bannerStartY = (screenHeight * BANNER_START_RATIO).toFloat() + val topPadding = (screenHeight * TOP_PADDING_RATIO).toInt() + + val excludeBottomRect = RectF( + 0f, + (screenHeight - buttonAreaHeight).toFloat(), + screenWidth.toFloat(), + screenHeight.toFloat(), + ) + canvas.drawRect(excludeBottomRect, paint) + + val excludeBannerRect = RectF( + 0f, + bannerStartY, + screenWidth.toFloat(), + screenHeight.toFloat(), + ) + canvas.drawRect(excludeBannerRect, paint) + + val excludeTopRect = RectF( + 0f, + 0f, + screenWidth.toFloat(), + topPadding.toFloat(), + ) + canvas.drawRect(excludeTopRect, paint) + + return bitmap + } + + /** + * Compresses the screenshot bitmap to reduce file size. + * + * @param bitmap The screenshot bitmap to compress + * @return Compressed image as byte array + */ + private suspend fun compressScreenshot(bitmap: Bitmap): ByteArray { + return withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + val originalBytes = outputStream.toByteArray() + + FileKit.compressImage( + bytes = originalBytes, + quality = COMPRESSION_QUALITY, + maxWidth = MAX_WIDTH, + maxHeight = MAX_HEIGHT, + imageFormat = ImageFormat.PNG, + ) + } + } + + /** + * Finds the content area above the advertisement banner using the testTag. + * + * @param rootView The root view to search in + * @return Rect representing the content area bounds, or null if not found + */ + private fun findContentArea(rootView: ViewGroup): android.graphics.Rect? { + return findViewWithTestTag(rootView, TEST_TAG_PAYMENT_SUCCESS_CONTENT)?.let { contentView -> + val location = IntArray(2) + contentView.getLocationInWindow(location) + + var viewWidth = contentView.width + var viewHeight = contentView.height + + if (viewWidth <= 0 || viewHeight <= 0) { + contentView.measure( + android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED), + android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED), + ) + viewWidth = contentView.measuredWidth + viewHeight = contentView.measuredHeight + } + + if (contentView is ViewGroup) { + contentView.layout(0, 0, viewWidth, viewHeight) + } + + if (viewWidth > 0 && viewHeight > 0) { + android.graphics.Rect( + location[0], + location[1], + location[0] + viewWidth, + location[1] + viewHeight, + ) + } else { + null + } + } + } + + /** + * Recursively searches for a view with the specified test tag. + * + * @param viewGroup The view group to search in + * @param testTag The test tag to search for + * @return The view with the test tag, or null if not found + */ + private fun findViewWithTestTag(viewGroup: ViewGroup, testTag: String): android.view.View? { + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) + + if (child.tag == testTag) { + return child + } + + if (child is ViewGroup) { + val found = findViewWithTestTag(child, testTag) + if (found != null) { + return found + } + } + } + return null + } +} diff --git a/core/ui/src/androidMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.android.kt b/core/ui/src/androidMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.android.kt new file mode 100644 index 000000000..27ba82152 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.android.kt @@ -0,0 +1,122 @@ +/* + * 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.ui.utils + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.view.ViewGroup +import androidx.compose.ui.Modifier +import androidx.core.view.drawToBitmap +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +/** + * Android implementation of ScreenshotUtils for taking screenshots of Compose screens. + * + * This implementation provides platform-specific screenshot functionality for Android, + * including the ability to exclude specific UI elements and generate context-aware filenames. + */ +actual object ScreenshotUtils { + + /** + * Provider function to retrieve the current [Activity]. + * This must be set before using screenshot functionality. + */ + private var activityProvider: () -> Activity = { + throw IllegalArgumentException( + "You need to implement the 'activityProvider' to provide the required Activity. " + + "Just make sure to set a valid activity using " + + "the 'setActivityProvider()' method.", + ) + } + + /** + * Sets the activity provider function to be used internally for context retrieval. + * + * This is required to initialize before calling any screenshot methods. + * + * @param provider A lambda that returns the current [Activity]. + */ + fun setActivityProvider(provider: () -> Activity) { + activityProvider = provider + } + + /** + * Takes a screenshot and immediately shares it using the platform's sharing mechanism. + * This overload allows passing context-specific details for better file naming. + * + * @param excludeModifiers List of modifiers that identify UI elements to exclude from the screenshot + * @param contextDetails Context-specific details to include in the filename + * @param onError Callback when screenshot or sharing fails + */ + actual suspend fun takeScreenshotAndShare( + excludeModifiers: List, + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + try { + withContext(Dispatchers.Main) { + val activity = activityProvider.invoke() + val rootView = activity.findViewById(android.R.id.content) + + val bitmap = rootView.drawToBitmap() + val canvas = Canvas(bitmap) + val paint = Paint().apply { + color = android.graphics.Color.TRANSPARENT + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + // Exclude bottom area if modifiers are provided + if (excludeModifiers.isNotEmpty()) { + val screenHeight = bitmap.height + val screenWidth = bitmap.width + val excludeAreaHeight = (screenHeight * 0.15f).toInt() + + val excludeRect = RectF( + 0f, + (screenHeight - excludeAreaHeight).toFloat(), + screenWidth.toFloat(), + screenHeight.toFloat(), + ) + + canvas.drawRect(excludeRect, paint) + } + + // Compress and share + withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 90, outputStream) + val bytes = outputStream.toByteArray() + + val fileName = contextDetails.generateFileName() + val shareFile = ShareFileModel( + mime = MimeType.IMAGE, + fileName = fileName, + bytes = bytes, + ) + + withContext(Dispatchers.Main) { + ShareUtils.shareFile(shareFile) + } + } + } + } catch (e: Exception) { + Logger.e(e) { "Failed to share screenshot: ${e.message}" } + onError("Failed to share screenshot: ${e.message}") + } + } +} diff --git a/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.kt b/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.kt new file mode 100644 index 000000000..03ae46bde --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.kt @@ -0,0 +1,75 @@ +/* + * 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.ui.utils + +import androidx.compose.ui.Modifier + +/** + * Platform-specific utilities for taking screenshots of Compose screens. + * + * This expect declaration should be implemented for each platform to handle + * the specifics of screenshot functionality. + */ +expect object ScreenshotUtils { + + /** + * Takes a screenshot and immediately shares it using the platform's sharing mechanism. + * This overload allows passing context-specific details for better file naming. + * + * @param excludeModifiers List of modifiers that identify UI elements to exclude from the screenshot + * @param contextDetails Context-specific details to include in the filename + * @param onError Callback when screenshot or sharing fails + */ + suspend fun takeScreenshotAndShare( + excludeModifiers: List = emptyList(), + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) +} + +/** + * Context details for screenshots to enable better file naming. + * + * @property screenType The type of screen being captured + * @property amount Optional amount information for payment-related screens + * @property recipientName Optional recipient name for payment-related screens + * @property timestamp Optional timestamp for the screenshot + * @property customSuffix Optional custom suffix to append to the filename + */ +data class ScreenshotContextDetails( + val screenType: String, + val amount: String? = null, + val recipientName: String? = null, + val timestamp: String? = null, + val customSuffix: String? = null, +) { + /** + * Generates a meaningful filename based on the context details. + * + * @return A sanitized filename suitable for file systems + */ + fun generateFileName(): String { + val sanitizedScreenType = screenType.replace(" ", "_").lowercase() + val sanitizedAmount = amount?.replace(" ", "_")?.replace(",", "")?.replace(".", "_")?.replace("₹", "INR") + val sanitizedRecipient = recipientName?.replace(" ", "_")?.replace(",", "")?.replace(".", "") + val sanitizedTimestamp = timestamp?.replace(" ", "_")?.replace(",", "")?.replace(":", "_")?.replace("am", "AM")?.replace("pm", "PM") + + val fileName = buildString { + append(sanitizedScreenType) + if (sanitizedAmount != null) append("_$sanitizedAmount") + if (sanitizedRecipient != null) append("_to_$sanitizedRecipient") + if (sanitizedTimestamp != null) append("_$sanitizedTimestamp") + if (customSuffix != null) append("_$customSuffix") + append(".png") + } + + return fileName + } +} diff --git a/core/ui/src/desktopMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.desktop.kt b/core/ui/src/desktopMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.desktop.kt new file mode 100644 index 000000000..506fa0284 --- /dev/null +++ b/core/ui/src/desktopMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.desktop.kt @@ -0,0 +1,30 @@ +/* + * 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.ui.utils + +import androidx.compose.ui.Modifier +import co.touchlab.kermit.Logger + +/** + * Desktop stub implementation of ScreenshotUtils. + * + * Screenshot functionality is not implemented for Desktop yet. + */ +actual object ScreenshotUtils { + + actual suspend fun takeScreenshotAndShare( + excludeModifiers: List, + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + Logger.w("Screenshot capture not implemented for Desktop yet") + onError("Screenshot capture not implemented for Desktop yet") + } +} diff --git a/core/ui/src/jsMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.js.kt b/core/ui/src/jsMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.js.kt new file mode 100644 index 000000000..fdf2661d3 --- /dev/null +++ b/core/ui/src/jsMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.js.kt @@ -0,0 +1,30 @@ +/* + * 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.ui.utils + +import androidx.compose.ui.Modifier +import co.touchlab.kermit.Logger + +/** + * JS stub implementation of ScreenshotUtils. + * + * Screenshot functionality is not available in JS environments. + */ +actual object ScreenshotUtils { + + actual suspend fun takeScreenshotAndShare( + excludeModifiers: List, + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + Logger.w("Screenshot capture not supported in JS environment") + onError("Screenshot capture not supported in JS environment") + } +} diff --git a/core/ui/src/nativeMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.native.kt b/core/ui/src/nativeMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.native.kt new file mode 100644 index 000000000..bf9535cd8 --- /dev/null +++ b/core/ui/src/nativeMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.native.kt @@ -0,0 +1,30 @@ +/* + * 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.ui.utils + +import androidx.compose.ui.Modifier +import co.touchlab.kermit.Logger + +/** + * iOS stub implementation of ScreenshotUtils. + * + * Screenshot functionality is not implemented for iOS yet. + */ +actual object ScreenshotUtils { + + actual suspend fun takeScreenshotAndShare( + excludeModifiers: List, + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + Logger.w("Screenshot capture not implemented for iOS yet") + onError("Screenshot capture not implemented for iOS yet") + } +} diff --git a/core/ui/src/wasmJsMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.wasmJs.kt b/core/ui/src/wasmJsMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.wasmJs.kt new file mode 100644 index 000000000..d9e500b97 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/mifospay/core/ui/utils/ScreenshotUtils.wasmJs.kt @@ -0,0 +1,30 @@ +/* + * 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.ui.utils + +import androidx.compose.ui.Modifier +import co.touchlab.kermit.Logger + +/** + * WASM/JS stub implementation of ScreenshotUtils. + * + * Screenshot functionality is not available in WASM/JS environments. + */ +actual object ScreenshotUtils { + + actual suspend fun takeScreenshotAndShare( + excludeModifiers: List, + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + Logger.w("Screenshot capture not supported in WASM/JS environment") + onError("Screenshot capture not supported in WASM/JS environment") + } +} diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt index 66513f281..0cbfbadb1 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt @@ -30,6 +30,7 @@ import org.mifospay.core.designsystem.component.MifosScaffold internal fun ScanQrCodeScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, modifier: Modifier = Modifier, viewModel: ScanQrViewModel = koinViewModel(), ) { @@ -44,6 +45,10 @@ internal fun ScanQrCodeScreen( navigateToSendScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToSendScreen).data) } + is ScanQrEvent.OnNavigateToPayeeDetails -> { + navigateToPayeeDetailsScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToPayeeDetails).data) + } + is ScanQrEvent.ShowToast -> { scope.launch { snackbarHostState.showSnackbar((eventFlow as ScanQrEvent.ShowToast).message) diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index cea7f82b7..33b8d7e20 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor class ScanQrViewModel : ViewModel() { @@ -22,10 +23,24 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - UpiQrCodeProcessor.decodeUpiString(data) + val isUpiQr = try { + UpiQrCodeProcessor.decodeUpiString(data) + true + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(data) + true + } else { + false + } + } _eventFlow.update { - ScanQrEvent.OnNavigateToSendScreen(data) + if (isUpiQr) { + ScanQrEvent.OnNavigateToPayeeDetails(data) + } else { + ScanQrEvent.OnNavigateToSendScreen(data) + } } true @@ -40,5 +55,6 @@ class ScanQrViewModel : ViewModel() { sealed interface ScanQrEvent { data class OnNavigateToSendScreen(val data: String) : ScanQrEvent + data class OnNavigateToPayeeDetails(val data: String) : ScanQrEvent data class ShowToast(val message: String) : ScanQrEvent } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt index 89bbe6b19..c8a3e25dd 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt @@ -23,11 +23,13 @@ fun NavController.navigateToScanQr(navOptions: NavOptions? = null) = fun NavGraphBuilder.scanQrScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, ) { composableWithSlideTransitions(route = SCAN_QR_ROUTE) { ScanQrCodeScreen( navigateBack = navigateBack, navigateToSendScreen = navigateToSendScreen, + navigateToPayeeDetailsScreen = navigateToPayeeDetailsScreen, ) } } diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.android.kt new file mode 100644 index 000000000..78eb293de --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.android.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import kotlinx.coroutines.launch +import org.mifospay.core.ui.utils.ScreenshotContextDetails +import org.mifospay.core.ui.utils.ShareUtils + +/** + * Android-specific implementation of PaymentDetailsScreen with screenshot functionality. + * + * This composable sets up the necessary providers for screenshot and sharing functionality + * and delegates the UI rendering to the common implementation. + */ +@Composable +actual fun PaymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier, + viewModel: PaymentDetailsViewModel, + transactionId: String, +) { + val context = LocalContext.current + val activity = remember { context as? Activity } + val coroutineScope = rememberCoroutineScope() + + DisposableEffect(activity) { + if (activity != null) { + ShareUtils.setActivityProvider { activity } + PaymentDetailsScreenshotUtils.setActivityProvider { activity } + } + onDispose { } + } + + val handleShareScreenshot = remember { + { + coroutineScope.launch { + try { + Logger.d("Taking payment details screenshot and sharing...") + + val state = viewModel.stateFlow.value + val contextDetails = ScreenshotContextDetails( + screenType = "Payment Details", + amount = state.formattedAmount, + recipientName = state.payeeName, + timestamp = state.transactionDate, + customSuffix = "UPI", + ) + + PaymentDetailsScreenshotUtils.takePaymentDetailsScreenshotAndShare( + contextDetails = contextDetails, + onError = { errorMessage -> + Logger.e("Screenshot failed: $errorMessage") + }, + ) + } catch (e: Exception) { + Logger.e(e) { "Failed to take screenshot: ${e.message}" } + } + } + Unit + } + } + + PaymentDetailsScreenDefault( + onBackClick = onBackClick, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + onShareScreenshot = handleShareScreenshot, + modifier = modifier, + viewModel = viewModel, + transactionId = transactionId, + ) +} diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreenshotUtils.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreenshotUtils.kt new file mode 100644 index 000000000..845109a8e --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreenshotUtils.kt @@ -0,0 +1,260 @@ +/* + * 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.app.Activity +import android.graphics.Bitmap +import android.view.View +import android.view.ViewGroup +import androidx.core.view.drawToBitmap +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.mifospay.core.ui.utils.MimeType +import org.mifospay.core.ui.utils.ScreenshotContextDetails +import org.mifospay.core.ui.utils.ShareFileModel +import org.mifospay.core.ui.utils.ShareUtils +import java.io.ByteArrayOutputStream + +// TODO Capture the whole content of this page +// currently this captures only visible screen between topbar and share screenshot button +/** + * Specialized screenshot utility for PaymentDetailsScreen. + * + * This utility is designed specifically for taking screenshots of the payment details screen + * while excluding the top bar and bottom button areas. It captures only the scrollable content + * area for clean, professional screenshots. + */ +object PaymentDetailsScreenshotUtils { + + private const val CONTENT_PADDING_DP = 16 + private const val COMPRESSION_QUALITY = 90 + private const val TEST_TAG_PAYMENT_DETAILS_CONTENT = "payment-details-content" + + /** + * Provider function to retrieve the current [Activity]. + * This must be set before using screenshot functionality. + */ + private var activityProvider: () -> Activity = { + throw IllegalArgumentException( + "You need to implement the 'activityProvider' to provide the required Activity. " + + "Just make sure to set a valid activity using " + + "the 'setActivityProvider()' method.", + ) + } + + /** + * Sets the activity provider function to be used internally for context retrieval. + * + * This is required to initialize before calling any screenshot methods. + * + * @param provider A lambda that returns the current [Activity]. + */ + fun setActivityProvider(provider: () -> Activity) { + activityProvider = provider + } + + /** + * Takes a screenshot of the PaymentDetailsScreen and shares it. + * + * @param contextDetails Payment context details for better file naming + * @param onError Callback when screenshot or sharing fails + */ + suspend fun takePaymentDetailsScreenshotAndShare( + contextDetails: ScreenshotContextDetails, + onError: (String) -> Unit, + ) { + try { + Logger.d("Taking payment details screenshot and sharing...") + val screenshotBytes = takePaymentDetailsScreenshotSync() + + withContext(Dispatchers.Main) { + val fileName = contextDetails.generateFileName() + val shareFile = ShareFileModel( + mime = MimeType.IMAGE, + fileName = fileName, + bytes = screenshotBytes, + ) + ShareUtils.shareFile(shareFile) + } + } catch (e: Exception) { + Logger.e(e) { "Failed to take payment details screenshot: ${e.message}" } + onError("Failed to take screenshot: ${e.message}") + } + } + + /** + * Takes a screenshot of the PaymentDetailsScreen synchronously and returns the bytes. + * + * @return Byte array of the compressed screenshot + */ + private suspend fun takePaymentDetailsScreenshotSync(): ByteArray { + return withContext(Dispatchers.Main) { + val activity = activityProvider.invoke() + val rootView = activity.findViewById(android.R.id.content) + + val screenshot = capturePaymentDetailsScreenshot(rootView) + compressScreenshot(screenshot) + } + } + + /** + * Captures a screenshot of the PaymentDetailsScreen while excluding the top bar and bottom button areas. + * + * @param rootView The root view to capture + * @return Bitmap of the screenshot with only the content area + */ + private fun capturePaymentDetailsScreenshot(rootView: ViewGroup): Bitmap { + val bitmap = rootView.drawToBitmap() + val contentArea = findContentArea(rootView) + + return if (contentArea != null && contentArea.width() > 0 && contentArea.height() > 0) { + captureContentAreaScreenshot(bitmap, contentArea, rootView.resources.displayMetrics.density) + } else { + captureFallbackScreenshot(bitmap) + } + } + + /** + * Captures screenshot using the identified content area with proper padding. + */ + private fun captureContentAreaScreenshot( + bitmap: Bitmap, + contentArea: android.graphics.Rect, + density: Float, + ): Bitmap { + val paddingPx = (CONTENT_PADDING_DP * density).toInt() + + val paddedLeft = maxOf(0, contentArea.left - paddingPx) + val paddedTop = maxOf(0, contentArea.top - paddingPx) + val paddedRight = minOf(bitmap.width, contentArea.right + paddingPx) + val paddedBottom = minOf(bitmap.height, contentArea.bottom + paddingPx) + + val paddedWidth = paddedRight - paddedLeft + val paddedHeight = paddedBottom - paddedTop + + val contentBitmap = Bitmap.createBitmap( + bitmap, + paddedLeft, + paddedTop, + paddedWidth, + paddedHeight, + ) + bitmap.recycle() + return contentBitmap + } + + /** + * Captures screenshot using fallback method when content area is not found. + * Excludes top bar and bottom button areas using estimated positions. + */ + private fun captureFallbackScreenshot(bitmap: Bitmap): Bitmap { + val screenHeight = bitmap.height + val screenWidth = bitmap.width + + // Estimate top bar height (status bar + app bar) + val topBarHeight = (screenHeight * 0.12f).toInt() + + // Estimate bottom button area height + val bottomButtonAreaHeight = (screenHeight * 0.15f).toInt() + + // Calculate the area to capture (between top bar and bottom button) + val captureTop = topBarHeight + val captureHeight = screenHeight - topBarHeight - bottomButtonAreaHeight + + Logger.d("Fallback screenshot: excluding top ${topBarHeight}px and bottom ${bottomButtonAreaHeight}px") + + if (captureHeight > 0) { + val croppedBitmap = Bitmap.createBitmap( + bitmap, + 0, + captureTop, + screenWidth, + captureHeight, + ) + bitmap.recycle() + return croppedBitmap + } else { + Logger.w("Invalid capture dimensions, returning original bitmap") + return bitmap + } + } + + /** + * Compresses the screenshot bitmap to reduce file size. + * + * @param bitmap The screenshot bitmap to compress + * @return Compressed image as byte array + */ + private suspend fun compressScreenshot(bitmap: Bitmap): ByteArray { + return withContext(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, COMPRESSION_QUALITY, outputStream) + val bytes = outputStream.toByteArray() + outputStream.close() + + bitmap.recycle() + bytes + } + } + + /** + * Finds the content area using the testTag. + * + * @param rootView The root view to search in + * @return Rect representing the content area bounds, or null if not found + */ + private fun findContentArea(rootView: ViewGroup): android.graphics.Rect? { + return findViewWithTestTag(rootView, TEST_TAG_PAYMENT_DETAILS_CONTENT)?.let { contentView -> + val location = IntArray(2) + contentView.getLocationInWindow(location) + + var viewWidth = contentView.width + var viewHeight = contentView.height + + if (viewWidth <= 0 || viewHeight <= 0) { + Logger.w("Content view has invalid dimensions: ${viewWidth}x$viewHeight") + return null + } + + android.graphics.Rect( + location[0], + location[1], + location[0] + viewWidth, + location[1] + viewHeight, + ) + } + } + + /** + * Recursively searches for a view with the specified test tag. + * + * @param root The root view to search in + * @param testTag The test tag to search for + * @return The view with the matching test tag, or null if not found + */ + private fun findViewWithTestTag(root: View, testTag: String): View? { + if (root.tag == testTag) { + return root + } + + if (root is ViewGroup) { + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) + val result = findViewWithTestTag(child, testTag) + if (result != null) { + return result + } + } + } + + return null + } +} diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.android.kt new file mode 100644 index 000000000..265646e99 --- /dev/null +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.android.kt @@ -0,0 +1,87 @@ +/* + * 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.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import kotlinx.coroutines.launch +import org.mifospay.core.ui.utils.PaymentSuccessScreenshotUtils +import org.mifospay.core.ui.utils.ScreenshotContextDetails +import org.mifospay.core.ui.utils.ShareUtils + +/** + * Android-specific implementation of PaymentSuccessScreen with screenshot functionality. + * + * This composable sets up the necessary providers for screenshot and sharing functionality + * and delegates the UI rendering to the common implementation. + */ +@Composable +actual fun PaymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier, + viewModel: PaymentSuccessViewModel, +) { + val context = LocalContext.current + val activity = remember { context as? Activity } + val coroutineScope = rememberCoroutineScope() + + DisposableEffect(activity) { + if (activity != null) { + PaymentSuccessScreenshotUtils.setActivityProvider { activity } + ShareUtils.setActivityProvider { activity } + } + onDispose { } + } + + val handleShareScreenshot = remember { + { + coroutineScope.launch { + try { + Logger.d("Taking payment success screenshot and sharing...") + + val state = viewModel.stateFlow.value + val contextDetails = ScreenshotContextDetails( + screenType = "Payment Success", + amount = state.formattedAmount, + recipientName = state.payeeName, + timestamp = state.timestamp, + customSuffix = "UPI", + ) + + PaymentSuccessScreenshotUtils.takePaymentSuccessScreenshotAndShare( + contextDetails = contextDetails, + onError = { errorMessage -> + Logger.e("Screenshot failed: $errorMessage") + }, + ) + } catch (e: Exception) { + Logger.e(e) { "Failed to take screenshot: ${e.message}" } + } + } + Unit + } + } + + PaymentSuccessScreenDefault( + onShareScreenshot = handleShareScreenshot, + onDone = onDone, + onNavigateToSendMoneyOptions = onNavigateToSendMoneyOptions, + modifier = modifier, + viewModel = viewModel, + ) +} diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt index 135592b86..5d2e1ff55 100644 --- a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt @@ -39,11 +39,12 @@ class QrScannerImp( override fun startScanning(): Flow { return callbackFlow { scanner.startScan() - .addOnSuccessListener { + .addOnSuccessListener { barcode -> launch { - send(it.rawValue) + val rawValue = barcode.rawValue + send(rawValue) } - }.addOnFailureListener { + }.addOnFailureListener { exception -> launch { send(null) } diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64d..829ec794e 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,41 @@ 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 + + Payee Details + Payee Profile + Paying %1$s + UPI ID: %1$s + Amount cannot be more than ₹ 5,00,000 + Amount must be at least ₹ 1 + Rupee Icon + Add note + Pay %1$s + Choose account to pay with + Bank Icon + Balance: + Check now + Change Account + Add bank account + Add Bank Account + Continue + Paying securely %1$s to + Paid to + + Payment Successful + Banking name: %1$s + Powered by UPI + Share screenshot + Done + Payment completed successfully \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/AmountUtils.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/AmountUtils.kt new file mode 100644 index 000000000..a850021ea --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/AmountUtils.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 org.mifospay.core.common.CurrencyFormatter + +/** + * Utility functions for converting between paise and rupees + * Navigation parameters use paise (stored as string) for precision + * UI displays use rupees with 2 decimal places + */ +object AmountUtils { + + /** + * Converts rupees (as string) to paise (as string) + * @param rupees Amount in rupees as string (e.g., "100.50") + * @return Amount in paise as string (e.g., "10050") + */ + fun rupeesToPaise(rupees: String): String { + return try { + val amount = rupees.toDoubleOrNull() ?: 0.0 + (amount * 100).toLong().toString() + } catch (e: NumberFormatException) { + "0" + } + } + + /** + * Converts paise (as string) to rupees (as string) with 2 decimal places + * @param paise Amount in paise as string (e.g., "10050") + * @return Amount in rupees as string with 2 decimal places (e.g., "100.50") + */ + fun paiseToRupees(paise: String): String { + return try { + val amount = paise.toLongOrNull() ?: 0L + val rupees = amount / 100.0 + val formatted = CurrencyFormatter.format( + balance = rupees, + currencyCode = "INR", + maximumFractionDigits = 2, + ) + formatted.replace("₹", "").trim() + } catch (e: NumberFormatException) { + "0.00" + } + } + + /** + * Formats rupees amount for UI display with proper formatting + * @param rupees Amount in rupees as string (e.g., "100.50") + * @return Formatted amount for UI (e.g., "₹100.50") + */ + fun formatRupeesForUI(rupees: String): String { + return try { + val amount = rupees.toDoubleOrNull() ?: 0.0 + return CurrencyFormatter.format( + balance = amount, + currencyCode = "INR", + maximumFractionDigits = 2, + ) + } catch (e: NumberFormatException) { + "₹0.00" + } + } + + /** + * Formats paise amount for UI display by converting to rupees first + * @param paise Amount in paise as string (e.g., "10050") + * @return Formatted amount for UI (e.g., "₹100.50") + */ + fun formatPaiseForUI(paise: String): String { + val rupees = paiseToRupees(paise) + return formatRupeesForUI(rupees) + } + + /** + * Validates if a paise amount is valid + * @param paise Amount in paise as string + * @return true if valid, false otherwise + */ + fun isValidPaise(paise: String): Boolean { + return try { + val amount = paise.toLongOrNull() + amount != null && amount >= 0 + } catch (e: NumberFormatException) { + false + } + } + + /** + * Validates if a rupees amount is valid + * @param rupees Amount in rupees as string + * @return true if valid, false otherwise + */ + fun isValidRupees(rupees: String): Boolean { + return try { + val amount = rupees.toDoubleOrNull() + amount != null && amount >= 0 + } catch (e: NumberFormatException) { + false + } + } + + /** + * Validates and formats amount input for the new input system + * Ensures amount starts with single digit, allows only one decimal point + * @param input Raw input string from user + * @return Validated and formatted amount string + */ + fun validateAndFormatAmountInput(input: String): String { + if (input.isEmpty()) return "" + + val cleanInput = input.replace(",", "") + + if (cleanInput == ".") return "0." + if (cleanInput.startsWith(".")) return "0$cleanInput" + + val parts = cleanInput.split(".") + if (parts.size > 2) return parts[0] + "." + parts[1] + + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + if (integerPart.isEmpty()) return "0." + if (integerPart.length > 6) return integerPart.take(6) + (if (parts.size > 1) ".$decimalPart" else "") + if (decimalPart.length > 2) return "$integerPart.${decimalPart.take(2)}" + + return cleanInput + } + + /** + * Checks if the amount input is valid + * @param input Raw input string from user + * @return true if input is valid, false otherwise + */ + fun isValidAmountInput(input: String): Boolean { + if (input.isEmpty()) return true + + val cleanInput = input.replace(",", "") + + if (cleanInput == "." || cleanInput == "0.") return true + if (cleanInput.startsWith(".")) return false + + val parts = cleanInput.split(".") + if (parts.size > 2) return false + + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + if (integerPart.isEmpty()) return false + if (integerPart.length > 6) return false + if (decimalPart.length > 2) return false + + val validIntegerPart = integerPart.all { it.isDigit() } + val validDecimalPart = decimalPart.isEmpty() || decimalPart.all { it.isDigit() } + + return validIntegerPart && validDecimalPart + } + + /** + * Formats input amount for display + * Shows only the numeric part without currency symbol + * @param amount Amount in rupees as string + * @return Formatted amount for display + */ + + // TODO handle edge cases for example decimal point is entered first. + fun formatAmountForInput(amount: String): String { + if (amount.isEmpty()) return "" + + val cleanAmount = amount.replace(",", "") + return try { + val amountValue = cleanAmount.toDoubleOrNull() ?: 0.0 + if (amountValue == 0.0) return "" + + val parts = amountValue.toString().split(".") + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + if (decimalPart.isEmpty() || decimalPart == "0") { + integerPart + } else if (decimalPart == "00") { + integerPart + } else if (decimalPart.endsWith("0")) { + "$integerPart.${decimalPart.dropLast(1)}" + } else { + "$integerPart.$decimalPart" + } + } catch (e: NumberFormatException) { + amount + } + } +} 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..6cc685b36 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -0,0 +1,1099 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.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.platform.LocalFocusManager +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 mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_add_bank_account +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_add_bank_account_desc +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_add_note +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_amount_below_minimum +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_amount_exceeds_limit +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_balance +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_icon +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_change_account +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_check_now +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_choose_account +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_pay_amount +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_payee_details_title +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_payee_profile +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_paying +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_rupee_icon +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_selected +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_upi_id +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosBottomSheet +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, + onNavigateToPaymentProcessing: (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.NavigateToUpiPin -> onNavigateToPaymentProcessing.invoke(event.state) + is PayeeDetailsEvent.NavigateToPaymentProcessing -> onNavigateToPaymentProcessing.invoke(event.state) + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_payee_details_title), + 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.UpdateInputAmount(amount)) + }, + onNoteChange = { note -> + viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) + }, + onNoteFieldFocused = { + viewModel.trySendAction(PayeeDetailsAction.NoteFieldFocused) + }, + onAmountFieldFocused = { + viewModel.trySendAction(PayeeDetailsAction.AmountFieldFocused) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + } + + if (!state.showAccountSelectionSheet) { + if (state.selectedAccount != null) { + // Show selected account and pay button at bottom + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SelectedAccountSection( + account = state.selectedAccount!!, + onChangeAccount = { + viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) + }, + ) + + ProceedButton( + state = state, + onProceedClick = { + viewModel.trySendAction(PayeeDetailsAction.ConfirmPayment) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + ProceedButton( + state = state, + onProceedClick = { + viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = KptTheme.spacing.lg, + bottom = KptTheme.spacing.lg, + ), + ) + } + } + } + } + + if (state.showAccountSelectionSheet) { + AccountSelectionBottomSheet( + state = state, + onAccountSelected = { account -> + viewModel.trySendAction(PayeeDetailsAction.SelectAccount(account)) + }, + onDismiss = { + viewModel.trySendAction(PayeeDetailsAction.DismissAccountSelection) + }, + onConfirmPayment = { + viewModel.trySendAction(PayeeDetailsAction.ConfirmPayment) + }, + ) + } + } +} + +@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 = stringResource(Res.string.feature_send_money_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 = stringResource(Res.string.feature_send_money_paying, decodedName.uppercase()), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + val contactInfo = if (state.isUpiCode) { + stringResource(Res.string.feature_send_money_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, + onAmountFieldFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + ExpandableAmountInput( + value = state.displayAmount, + onValueChange = onAmountChange, + enabled = state.isAmountEditable, + modifier = Modifier.wrapContentWidth(), + onFieldFocused = onAmountFieldFocused, + ) + + AnimatedVisibility( + visible = state.showMaxAmountMessage || state.showMinAmountMessage, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)), + ) { + val isVisible = state.showMaxAmountMessage || state.showMinAmountMessage + val vibrationOffset by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = repeatable( + iterations = 3, + animation = tween(100, delayMillis = 0), + ), + label = "vibration", + ) + + Text( + text = when { + state.showMaxAmountMessage -> stringResource(Res.string.feature_send_money_amount_exceeds_limit) + state.showMinAmountMessage -> stringResource(Res.string.feature_send_money_amount_below_minimum) + else -> "" + }, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.error, + modifier = Modifier + .padding(top = KptTheme.spacing.xs) + .graphicsLayer { + translationX = if (isVisible) { + (vibrationOffset * 10f * (if (vibrationOffset % 2 == 0f) 1f else -1f)) + } else { + 0f + } + }, + ) + } + + ExpandableNoteInput( + value = state.note, + onValueChange = onNoteChange, + onFieldFocused = onNoteFieldFocused, + modifier = Modifier.wrapContentWidth(), + ) + } +} + +@Composable +private fun ExpandableAmountInput( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, + onFieldFocused: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val displayValue = value.ifEmpty { "" } + + /** + * Calculate width based on the display value + * When empty, use minimal width + * When user enters digits, expand dynamically + * Maximum amount is ₹5,00,000 (6 digits + decimal + up to 2 decimal places = max 9 characters) + */ + val textFieldWidth = when { + displayValue.isEmpty() -> 24.dp + displayValue.length == 1 -> 32.dp + displayValue.length == 2 -> 48.dp + displayValue.length == 3 -> 64.dp + displayValue.length == 4 -> 80.dp + displayValue.length == 5 -> 96.dp + displayValue.length == 6 -> 112.dp + displayValue.length == 7 -> 128.dp + displayValue.length == 8 -> 144.dp + displayValue.length == 9 -> 160.dp + else -> 160.dp + } + + 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 = stringResource(Res.string.feature_send_money_rupee_icon), + tint = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + BasicTextField( + value = displayValue, + onValueChange = { newValue -> + onValueChange(newValue) + }, + 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) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + onFieldFocused() + } + }, + 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 = stringResource(Res.string.feature_send_money_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 focusManager = LocalFocusManager.current + val isAmountValid = if (state.isUpiCode) { + state.amount.isNotEmpty() && + AmountUtils.isValidPaise(state.amount) && + AmountUtils.paiseToRupees(state.amount).toDoubleOrNull()?.let { it >= 0 } == true && + !state.isAmountExceedingMax && + !state.isAmountBelowMin + } else { + state.amount.isNotEmpty() && + AmountUtils.isValidPaise(state.amount) && + AmountUtils.paiseToRupees(state.amount).toDoubleOrNull()?.let { it >= 1 } == true && + !state.isAmountExceedingMax + } + val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() + val isAmountPrefilled = !state.isAmountEditable + val hasSelectedAccount = state.selectedAccount != null + val showCheckMark = isAmountValid && isContactValid && (isAmountPrefilled || state.hasNoteFieldBeenFocused || hasSelectedAccount) + + val isButtonEnabled = if (hasSelectedAccount) { + isAmountValid && isContactValid + } else { + isAmountValid && isContactValid && state.amount.isNotEmpty() + } + + Button( + onClick = { + focusManager.clearFocus() + onProceedClick() + }, + enabled = isButtonEnabled, + modifier = if (hasSelectedAccount) { + modifier + .fillMaxWidth() + .height(56.dp) + } else { + modifier.size(56.dp) + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isButtonEnabled) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.surfaceVariant + }, + contentColor = if (isButtonEnabled) { + KptTheme.colorScheme.onPrimary + } else { + KptTheme.colorScheme.onSurfaceVariant + }, + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + contentPadding = if (hasSelectedAccount) { + PaddingValues(horizontal = KptTheme.spacing.lg) + } else { + PaddingValues(0.dp) + }, + ) { + if (hasSelectedAccount) { + Text( + text = stringResource(Res.string.feature_send_money_pay_amount, AmountUtils.formatPaiseForUI(state.amount)), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } else { + Icon( + imageVector = when { + showCheckMark -> MifosIcons.CheckRounded + else -> MifosIcons.ArrowForward + }, + contentDescription = when { + showCheckMark -> "Proceed" + else -> "Next" + }, + modifier = Modifier.size(32.dp), + ) + } + } +} + +// TODO improve bottomsheet UI/UX +@Composable +private fun AccountSelectionBottomSheet( + state: PayeeDetailsState, + onAccountSelected: (BankAccount) -> Unit, + onDismiss: () -> Unit, + onConfirmPayment: () -> Unit, + modifier: Modifier = Modifier, +) { + val dummyAccounts = listOf( + BankAccount( + id = "1", + bankName = "State Bank of India", + accountNumber = "****1234", + isDefault = true, + ), + BankAccount( + id = "2", + bankName = "HDFC Bank", + accountNumber = "****5678", + isDefault = false, + ), + ) + + MifosBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(KptTheme.spacing.sm), + ) { + Text( + text = stringResource(Res.string.feature_send_money_choose_account), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + dummyAccounts.forEach { account -> + AccountItem( + account = account, + isSelected = state.selectedAccount?.id == account.id, + onAccountClick = { onAccountSelected(account) }, + ) + } + + AddBankAccountItem() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + ) { + val focusManager = LocalFocusManager.current + Button( + onClick = { + focusManager.clearFocus() + onConfirmPayment() + }, + enabled = state.selectedAccount != null, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (state.selectedAccount != null) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.surfaceVariant + }, + contentColor = if (state.selectedAccount != null) { + KptTheme.colorScheme.onPrimary + } else { + KptTheme.colorScheme.onSurfaceVariant + }, + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + contentPadding = PaddingValues(horizontal = KptTheme.spacing.lg), + ) { + Text( + text = stringResource(Res.string.feature_send_money_pay_amount, AmountUtils.formatPaiseForUI(state.amount)), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + } + } +} + +@Composable +private fun AccountItem( + account: BankAccount, + isSelected: Boolean, + onAccountClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onAccountClick() }, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = stringResource(Res.string.feature_send_money_bank_icon), + modifier = Modifier.size(32.dp), + tint = KptTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.md)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = account.bankName, + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + Text( + text = account.accountNumber, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + // TODO implement check now for balance + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = stringResource(Res.string.feature_send_money_balance), + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.feature_send_money_check_now), + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.primary, + modifier = Modifier.clickable { }, + ) + } + } + + if (isSelected) { + Icon( + imageVector = MifosIcons.CheckCircle, + contentDescription = stringResource(Res.string.feature_send_money_selected), + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun SelectedAccountSection( + account: BankAccount, + onChangeAccount: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg) + .clickable { onChangeAccount() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = stringResource(Res.string.feature_send_money_bank_icon), + modifier = Modifier.size(32.dp), + tint = KptTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.md)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = account.bankName, + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + Text( + text = account.accountNumber, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = stringResource(Res.string.feature_send_money_balance), + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.feature_send_money_check_now), + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.primary, + modifier = Modifier.clickable { }, + ) + } + } + + Icon( + imageVector = MifosIcons.KeyboardArrowDown, + contentDescription = stringResource(Res.string.feature_send_money_change_account), + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AddBankAccountItem( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = stringResource(Res.string.feature_send_money_add_bank_account_desc), + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.md)) + + Text( + text = stringResource(Res.string.feature_send_money_add_bank_account), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.primary, + ) + } + } +} + +@Preview +@Composable +fun PayeeDetailsScreenPreview() { + PayeeDetailsScreen( + onBackClick = {}, + onNavigateToPaymentProcessing = {}, + modifier = Modifier, + // TODO: Figure out how to instantiate 'PayeeDetailsViewModel' + // viewModel = koinViewModel(), + ) +} + +@Preview +@Composable +fun PayeeProfileSectionPreview() { + val state = PayeeDetailsState( + payeeName = "John Doe", + upiId = "john.doe@upi", + phoneNumber = "1234567890", + amount = "100.00", + note = "Test payment", + isAmountEditable = true, + isUpiCode = true, + isLoading = false, + showMaxAmountMessage = false, + hasNoteFieldBeenFocused = false, + showAccountSelectionSheet = false, + selectedAccount = null, + ) + PayeeProfileSection(state = state, modifier = Modifier) +} + +@Preview +@Composable +fun PaymentDetailsSectionPreview() { + val state = PayeeDetailsState( + payeeName = "John Doe", + upiId = "john.doe@upi", + phoneNumber = "1234567890", + amount = "100.00", + note = "Test payment", + isAmountEditable = true, + isUpiCode = true, + isLoading = false, + showMaxAmountMessage = false, + hasNoteFieldBeenFocused = false, + showAccountSelectionSheet = false, + selectedAccount = null, + ) + PaymentDetailsSection( + state = state, + onAmountChange = {}, + onNoteChange = {}, + onNoteFieldFocused = {}, + onAmountFieldFocused = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun ExpandableAmountInputPreview() { + ExpandableAmountInput( + value = "100.00", + onValueChange = {}, + enabled = true, + modifier = Modifier, + onFieldFocused = {}, + ) +} + +@Preview +@Composable +fun ExpandableNoteInputPreview() { + ExpandableNoteInput( + value = "Test note", + onValueChange = {}, + onFieldFocused = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun ProceedButtonPreview() { + val state = PayeeDetailsState( + payeeName = "John Doe", + upiId = "john.doe@upi", + phoneNumber = "1234567890", + amount = "100.00", + note = "Test payment", + isAmountEditable = true, + isUpiCode = true, + isLoading = false, + showMaxAmountMessage = false, + hasNoteFieldBeenFocused = false, + showAccountSelectionSheet = false, + selectedAccount = null, + ) + ProceedButton( + state = state, + onProceedClick = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun AccountSelectionBottomSheetPreview() { + val state = PayeeDetailsState( + payeeName = "John Doe", + upiId = "john.doe@upi", + phoneNumber = "1234567890", + amount = "100.00", + note = "Test payment", + isAmountEditable = true, + isUpiCode = true, + isLoading = false, + showMaxAmountMessage = false, + hasNoteFieldBeenFocused = false, + showAccountSelectionSheet = false, + selectedAccount = null, + ) + AccountSelectionBottomSheet( + state = state, + onAccountSelected = {}, + onDismiss = {}, + onConfirmPayment = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun AccountItemPreview() { + val account = BankAccount( + id = "1", + bankName = "State Bank of India", + accountNumber = "****1234", + isDefault = true, + ) + AccountItem( + account = account, + isSelected = true, + onAccountClick = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun SelectedAccountSectionPreview() { + val account = BankAccount( + id = "1", + bankName = "State Bank of India", + accountNumber = "****1234", + isDefault = true, + ) + SelectedAccountSection( + account = account, + onChangeAccount = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun AddBankAccountItemPreview() { + AddBankAccountItem(modifier = Modifier) +} 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..84d104619 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -0,0 +1,304 @@ +/* + * 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") + } + + val amountInPaise = if (qrCodeData.amount.isNotEmpty()) { + AmountUtils.rupeesToPaise(qrCodeData.amount) + } else { + "" + } + + mutableStateFlow.update { + it.copy( + payeeName = qrCodeData.payeeName, + upiId = qrCodeData.payeeVpa, + phoneNumber = "", + amount = amountInPaise, + note = qrCodeData.transactionNote, + isAmountEditable = qrCodeData.amount.isEmpty(), + isUpiCode = true, + ) + } + } + } + + override fun handleAction(action: PayeeDetailsAction) { + when (action) { + is PayeeDetailsAction.NavigateBack -> { + sendEvent(PayeeDetailsEvent.NavigateBack) + } + + is PayeeDetailsAction.UpdateInputAmount -> { + val validatedAmount = AmountUtils.validateAndFormatAmountInput(action.inputAmount) + val isValidAmount = AmountUtils.isValidAmountInput(validatedAmount) + + if (isValidAmount) { + val amountInPaise = if (validatedAmount.isNotEmpty()) { + AmountUtils.rupeesToPaise(validatedAmount) + } else { + "" + } + + val amountValue = validatedAmount.toDoubleOrNull() ?: 0.0 + val showMaxMessage = amountValue > 500000 + val showMinMessage = amountValue > 0 && amountValue < 1 + + val currentAmount = stateFlow.value.amount + val shouldClearAccount = amountInPaise != currentAmount + + mutableStateFlow.value = stateFlow.value.copy( + amount = amountInPaise, + inputAmount = validatedAmount, + showMaxAmountMessage = showMaxMessage, + showMinAmountMessage = showMinMessage, + selectedAccount = if (shouldClearAccount) null else stateFlow.value.selectedAccount, + ) + + if (showMaxMessage) { + viewModelScope.launch { + delay(2000) + mutableStateFlow.value = stateFlow.value.copy( + showMaxAmountMessage = false, + ) + } + } + + if (showMinMessage) { + viewModelScope.launch { + delay(2000) + mutableStateFlow.value = stateFlow.value.copy( + showMinAmountMessage = false, + ) + } + } + } + } + is PayeeDetailsAction.UpdateNote -> { + val currentNote = stateFlow.value.note + val shouldClearAccount = action.note != currentNote + + mutableStateFlow.value = stateFlow.value.copy( + note = action.note, + selectedAccount = if (shouldClearAccount) null else stateFlow.value.selectedAccount, + ) + } + is PayeeDetailsAction.NoteFieldFocused -> { + mutableStateFlow.value = stateFlow.value.copy( + hasNoteFieldBeenFocused = true, + selectedAccount = null, + ) + } + is PayeeDetailsAction.AmountFieldFocused -> { + mutableStateFlow.value = stateFlow.value.copy(selectedAccount = null) + } + is PayeeDetailsAction.ProceedToPayment -> { + if (stateFlow.value.selectedAccount == null) { + val defaultAccount = BankAccount( + id = "1", + bankName = "State Bank of India", + accountNumber = "****1234", + isDefault = true, + ) + mutableStateFlow.value = stateFlow.value.copy( + selectedAccount = defaultAccount, + ) + } else { + mutableStateFlow.value = stateFlow.value.copy(showAccountSelectionSheet = true) + } + } + is PayeeDetailsAction.SelectAccount -> { + mutableStateFlow.value = stateFlow.value.copy( + selectedAccount = action.account, + showAccountSelectionSheet = false, + ) + } + is PayeeDetailsAction.DismissAccountSelection -> { + mutableStateFlow.value = stateFlow.value.copy(showAccountSelectionSheet = false) + } + is PayeeDetailsAction.ContinueFromBottomSheet -> { + mutableStateFlow.value = stateFlow.value.copy(showAccountSelectionSheet = false) + } + is PayeeDetailsAction.ConfirmPayment -> { + val currentState = stateFlow.value + sendEvent(PayeeDetailsEvent.NavigateToUpiPin(currentState)) + } + } + } +} + +data class PayeeDetailsState( + val payeeName: String = "", + val upiId: String = "", + val phoneNumber: String = "", + val amount: String = "", + val inputAmount: String = "", + val note: String = "", + val isAmountEditable: Boolean = true, + val isUpiCode: Boolean = false, + val isLoading: Boolean = false, + val showMaxAmountMessage: Boolean = false, + val showMinAmountMessage: Boolean = false, + val hasNoteFieldBeenFocused: Boolean = false, + val showAccountSelectionSheet: Boolean = false, + val selectedAccount: BankAccount? = null, + val refId: String = "", +) { + val formattedAmount: String + get() = if (amount.isEmpty()) { + "0" + } else { + val rupees = AmountUtils.paiseToRupees(amount) + formatAmountWithCommas(rupees) + } + + val displayAmount: String + get() = if (inputAmount.isNotEmpty()) { + inputAmount + } else if (amount.isNotEmpty()) { + AmountUtils.formatAmountForInput(AmountUtils.paiseToRupees(amount)) + } else { + "" + } + + val isAmountExceedingMax: Boolean + get() { + val rupees = if (amount.isNotEmpty()) AmountUtils.paiseToRupees(amount) else "0.00" + return rupees.toDoubleOrNull()?.let { it > 500000 } ?: false + } + + val isAmountBelowMin: Boolean + get() { + val rupees = if (amount.isNotEmpty()) AmountUtils.paiseToRupees(amount) else "0.00" + return rupees.toDoubleOrNull()?.let { it < 1 } ?: 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 + } + } +} + +data class BankAccount( + val id: String, + val bankName: String, + val accountNumber: String, + val isDefault: Boolean = false, +) + +sealed interface PayeeDetailsEvent { + data object NavigateBack : PayeeDetailsEvent + data class NavigateToUpiPin(val state: PayeeDetailsState) : PayeeDetailsEvent + data class NavigateToPaymentProcessing(val state: PayeeDetailsState) : PayeeDetailsEvent +} + +sealed interface PayeeDetailsAction { + data object NavigateBack : PayeeDetailsAction + data class UpdateInputAmount(val inputAmount: String) : PayeeDetailsAction + data class UpdateNote(val note: String) : PayeeDetailsAction + data object NoteFieldFocused : PayeeDetailsAction + data object AmountFieldFocused : PayeeDetailsAction + data object ProceedToPayment : PayeeDetailsAction + data class SelectAccount(val account: BankAccount) : PayeeDetailsAction + data object DismissAccountSelection : PayeeDetailsAction + data object ContinueFromBottomSheet : PayeeDetailsAction + data object ConfirmPayment : 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/PaymentChatHistoryScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentChatHistoryScreen.kt new file mode 100644 index 000000000..d51468bb0 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentChatHistoryScreen.kt @@ -0,0 +1,497 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Button +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.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.AvatarBox +import template.core.base.designsystem.theme.KptTheme + +// TODO make the topbar back button consistent with other screens +@Composable +fun PaymentChatHistoryScreen( + onBackClick: () -> Unit, + onPaymentClick: () -> Unit, + onTransactionClick: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: PaymentChatHistoryViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + MifosScaffold( + modifier = modifier.fillMaxSize(), + topBar = { + UserProfileTopBar( + userType = state.userType, + businessName = state.businessName, + bankingName = state.bankingName, + upiId = state.upiId, + profileImageUrl = state.profileImageUrl, + onBackClick = onBackClick, + ) + }, + containerColor = KptTheme.colorScheme.background, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + PaymentHistoryContent( + paymentHistory = state.paymentHistory, + onTransactionClick = onTransactionClick, + modifier = Modifier.weight(1f), + ) + + PaymentActionBar( + onPaymentClick = onPaymentClick, + onMessageSend = viewModel::sendMessage, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun UserProfileTopBar( + userType: UserType, + businessName: String, + bankingName: String, + upiId: String, + profileImageUrl: String?, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = MifosIcons.Back, + contentDescription = "Back", + tint = KptTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + } + + AvatarBox( + name = if (userType == UserType.BUSINESS) businessName else bankingName, + size = 40, + ) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = if (userType == UserType.BUSINESS) businessName else bankingName, + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + + if (userType == UserType.INDIVIDUAL && upiId.isNotEmpty()) { + Text( + text = upiId, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PaymentHistoryContent( + paymentHistory: List, + onTransactionClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(paymentHistory) { group -> + PaymentHistoryGroup( + group = group, + onTransactionClick = onTransactionClick, + ) + } + } +} + +@Composable +private fun PaymentHistoryGroup( + group: PaymentHistoryGroup, + onTransactionClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(KptTheme.colorScheme.outline.copy(alpha = 0.3f)), + ) + + Text( + text = group.date, + style = KptTheme.typography.labelLarge, + color = KptTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(KptTheme.colorScheme.outline.copy(alpha = 0.3f)), + ) + } + + group.transactions.forEach { transaction -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (transaction.isSent) Alignment.CenterEnd else Alignment.CenterStart, + ) { + PaymentCard( + transaction = transaction, + onClick = { onTransactionClick(transaction.transactionId) }, + modifier = Modifier.width(224.dp), + ) + } + + if (transaction != group.transactions.last()) { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun PaymentCard( + transaction: PaymentTransaction, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = if (transaction.isSent) { + KptTheme.colorScheme.primaryContainer + } else { + KptTheme.colorScheme.secondaryContainer + }, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Payment to ${getFirstName(transaction.recipientName)}", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + + if (transaction.note.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = transaction.note, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatAmount(transaction.amount), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = MifosIcons.CheckCircle, + contentDescription = "Paid", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(16.dp), + ) + Text( + text = "Paid - ${formatPaymentDate(transaction.paymentDate)}", + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + + Icon( + imageVector = MifosIcons.ChevronRight, + contentDescription = "View Details", + tint = KptTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun PaymentActionBar( + onPaymentClick: () -> Unit, + onMessageSend: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = KptTheme.colorScheme.surface, + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onPaymentClick, + modifier = Modifier.weight(0.3f), + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + contentColor = KptTheme.colorScheme.onPrimary, + ), + shape = RoundedCornerShape(24.dp), + ) { + Text( + text = "Pay", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + } + + MessageInput( + onMessageSend = onMessageSend, + modifier = Modifier.weight(0.7f), + ) + } + } +} + +@Composable +private fun MessageInput( + onMessageSend: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var messageText by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .background( + color = KptTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(24.dp), + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + BasicTextField( + value = messageText, + onValueChange = { messageText = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = TextStyle( + fontSize = 14.sp, + color = KptTheme.colorScheme.onSurface, + ), + decorationBox = { innerTextField -> + if (messageText.text.isEmpty()) { + Text( + text = "Message...", + style = TextStyle( + fontSize = 14.sp, + color = KptTheme.colorScheme.onSurfaceVariant, + ), + ) + } + innerTextField() + }, + singleLine = true, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { + if (messageText.text.isNotEmpty()) { + onMessageSend(messageText.text) + messageText = TextFieldValue("") + } + }, + modifier = Modifier + .size(48.dp) + .background( + color = KptTheme.colorScheme.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = MifosIcons.Send, + contentDescription = "Send Message", + tint = KptTheme.colorScheme.onPrimary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private fun formatPaymentDate(dateString: String): String { + return try { + // Extract day, month, and year from the date string (e.g., "25 Sep 2023") + val parts = dateString.split(" ") + if (parts.size == 3) { + val day = parts[0] + val month = parts[1] + val year = parts[2] + "$day $month $year" + } else { + dateString + } + } catch (e: Exception) { + dateString + } +} + +private fun getFirstName(fullName: String): String { + return fullName.trim().split(" ").firstOrNull() ?: fullName +} + +private fun formatAmount(amount: String): String { + return try { + val amountValue = amount.replace(",", "").toDoubleOrNull() ?: 0.0 + CurrencyFormatter.format( + balance = amountValue, + currencyCode = "INR", + maximumFractionDigits = 2, + ) + } catch (e: Exception) { + "₹$amount" + } +} + +enum class UserType { + BUSINESS, + INDIVIDUAL, +} + +data class PaymentTransaction( + val transactionId: String, + val recipientName: String, + val amount: String, + val note: String, + val paymentDate: String, + val timestamp: Long, + val isSent: Boolean, +) + +data class PaymentHistoryGroup( + val date: String, + val transactions: List, +) + +data class PaymentChatHistoryState( + val userType: UserType = UserType.INDIVIDUAL, + val businessName: String = "", + val bankingName: String = "", + val upiId: String = "", + val profileImageUrl: String? = null, + val paymentHistory: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentChatHistoryViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentChatHistoryViewModel.kt new file mode 100644 index 000000000..2c2c823bc --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentChatHistoryViewModel.kt @@ -0,0 +1,124 @@ +/* + * 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.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.datetime.Clock + +/** + * ViewModel for the Payment Chat History screen + * Manages payment history state and user interactions + * Currently uses placeholder data for demonstration + */ +class PaymentChatHistoryViewModel : ViewModel() { + + private val _stateFlow = MutableStateFlow(createPlaceholderState()) + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + /** + * Sends a message to the recipient + * Currently just logs the message for demonstration + */ + fun sendMessage(message: String) { + // TODO: Implement actual message sending logic + // For now, just log the message + println("Message sent: $message") + } + + /** + * Creates placeholder data for demonstration purposes + * Includes sample payment history with different user types + */ + private fun createPlaceholderState(): PaymentChatHistoryState { + return PaymentChatHistoryState( + userType = UserType.INDIVIDUAL, + businessName = "TechCorp Solutions", + bankingName = "Jane Smith", + upiId = "jane.smith@upi", + profileImageUrl = null, + paymentHistory = createPlaceholderPaymentHistory(), + isLoading = false, + error = null, + ) + } + + /** + * Creates sample payment history data grouped by date + * Demonstrates the chat-like payment history interface + * Most recent transactions appear at the bottom (like chat messages) + */ + private fun createPlaceholderPaymentHistory(): List { + return listOf( + PaymentHistoryGroup( + date = "25 Sept 2025, 3:15 PM", + transactions = listOf( + PaymentTransaction( + transactionId = "txn_005", + recipientName = "Jane Smith", + amount = "3,200.00", + note = "Shopping payment", + paymentDate = "25 Sep 2025", + timestamp = Clock.System.now().toEpochMilliseconds() - 172800000, + isSent = true, + ), + ), + ), + PaymentHistoryGroup( + date = "26 Sept 2025, 8:45 PM", + transactions = listOf( + PaymentTransaction( + transactionId = "txn_003", + recipientName = "Jane Smith", + amount = "2,000.00", + note = "Dinner payment", + paymentDate = "26 Sep 2025", + timestamp = Clock.System.now().toEpochMilliseconds() - 86400000, + isSent = true, + ), + PaymentTransaction( + transactionId = "txn_004", + recipientName = "You", + amount = "750.00", + note = "Movie tickets", + paymentDate = "26 Sep 2025", + timestamp = Clock.System.now().toEpochMilliseconds() - 90000000, + isSent = false, + ), + ), + ), + PaymentHistoryGroup( + date = "27 Sept 2025, 2:30 PM", + transactions = listOf( + PaymentTransaction( + transactionId = "txn_001", + recipientName = "Jane Smith", + amount = "1,500.00", + note = "Lunch payment", + paymentDate = "27 Sep 2025", + timestamp = Clock.System.now().toEpochMilliseconds(), + isSent = true, + ), + PaymentTransaction( + transactionId = "txn_002", + recipientName = "Jane Smith", + amount = "500.00", + note = "Coffee and snacks", + paymentDate = "27 Sep 2025", + timestamp = Clock.System.now().toEpochMilliseconds() - 3600000, + isSent = true, + ), + ), + ), + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.kt new file mode 100644 index 000000000..75b99928a --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.kt @@ -0,0 +1,707 @@ +/* + * 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.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +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 PaymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PaymentDetailsViewModel = koinViewModel(), + transactionId: String, +) + +@Composable +internal fun PaymentDetailsScreenDefault( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PaymentDetailsViewModel = koinViewModel(), + transactionId: String, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val scrollState = rememberScrollState() + var isButtonVisible by remember { mutableStateOf(true) } + + LaunchedEffect(transactionId) { + viewModel.initialize(transactionId) + } + + LaunchedEffect(scrollState.value) { + isButtonVisible = scrollState.value <= 100 + } + + MifosScaffold( + modifier = modifier.fillMaxSize(), + topBar = { + MifosTopBar( + topBarTitle = "Payment Details", + backPress = onBackClick, + ) + }, + containerColor = KptTheme.colorScheme.background, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(bottom = 80.dp) + .testTag("payment-details-content"), + ) { + ProfileAndRecipientSection( + state = state, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + PaymentSummarySection( + state = state, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + StatusSection( + state = state, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + HorizontalDivider( + Modifier.width(300.dp), + DividerDefaults.Thickness, + KptTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + } + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + TransactionDateTimeSection( + state = state, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg) + .background( + color = KptTheme.colorScheme.surface, + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + ) + .border( + width = 1.dp, + color = KptTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + ) + .padding(KptTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + TransactionMetadataSection( + state = state, + modifier = Modifier.fillMaxWidth(), + ) + + DetailedInfoSection( + state = state, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(KptTheme.spacing.xxl)) + + BrandingSection( + modifier = Modifier.fillMaxWidth(), + ) + } + + if (isButtonVisible) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = KptTheme.colorScheme.surface, + shadowElevation = 8.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg) + .padding(vertical = KptTheme.spacing.lg), + ) { + Button( + onClick = onShareScreenshot, + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Share Screenshot", + style = KptTheme.typography.labelLarge, + fontWeight = FontWeight.Normal, + ) + } + } + } + } + } + } +} + +@Composable +private fun ProfileAndRecipientSection( + state: PaymentDetailsState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + if (state.profileImageUrl != null) { + AvatarBox( + name = state.payeeName, + size = 80, + ) + } else { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(KptTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.payeeName.firstOrNull()?.uppercase() ?: "?", + style = KptTheme.typography.headlineLarge, + color = KptTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Normal, + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = state.payeeName, + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + text = if (state.isBusiness) { + state.bankingName + } else { + "${state.phoneNumber} · ${state.upiId}" + }, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun PaymentSummarySection( + state: PaymentDetailsState, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = state.formattedAmount, + style = KptTheme.typography.displayMedium, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + if (state.note.isNotBlank()) { + Text( + text = state.note, + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + if (state.isPaymentSuccessful) { + Button( + onClick = onPayAgainClick, + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + ), + modifier = Modifier.wrapContentWidth(), + ) { + Text( + text = "Pay Again", + style = KptTheme.typography.labelLarge, + fontWeight = FontWeight.Normal, + ) + } + } else { + Button( + onClick = onRetryClick, + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.error, + ), + modifier = Modifier.wrapContentWidth(), + ) { + Text( + text = "Retry", + style = KptTheme.typography.labelLarge, + fontWeight = FontWeight.Normal, + ) + } + } + } +} + +@Composable +private fun StatusSection( + state: PaymentDetailsState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + if (state.isPaymentSuccessful) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Icon( + imageVector = MifosIcons.CheckCircle, + contentDescription = "Payment Successful", + modifier = Modifier.size(24.dp), + tint = Color(0xFF4CAF50), + ) + Text( + text = "Completed", + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = Color(0xFF4CAF50), + ) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = "Payment Failed", + modifier = Modifier.size(48.dp), + tint = KptTheme.colorScheme.error, + ) + Text( + text = "Payment Failed", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun TransactionDateTimeSection( + state: PaymentDetailsState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.transactionDate, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } +} + +// TODO improve UI/UX + +@Composable +private fun TransactionMetadataSection( + state: PaymentDetailsState, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(vertical = KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + Icon( + imageVector = MifosIcons.Bank, + contentDescription = "Bank", + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = state.payerBankName, + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurface, + ) + Text( + text = state.payerAccountLast4Digits, + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider() + + if (isExpanded) { + PaymentTimelineSection( + state = state, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() + } + } +} + +@Composable +private fun PaymentTimelineSection( + state: PaymentDetailsState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + TimelineStep( + step = "Payment Started", + isCompleted = true, + isFirst = true, + ) + + if (state.isPaymentSuccessful) { + TimelineStep( + step = "Payment received by ${state.payeeName}", + isCompleted = true, + ) + + TimelineStep( + step = if (state.isBusiness) "Purchase confirmed" else "Payment Completed", + isCompleted = true, + isLast = true, + ) + } else { + TimelineStep( + step = "Payment to ${state.payeeName} failed. Any money debited would be refunded within 3 working days.", + isCompleted = false, + isError = true, + ) + + TimelineStep( + step = if (state.isBusiness) "Waiting for purchase confirmation" else "Waiting for payment completion", + isCompleted = false, + isLast = true, + ) + } + } +} + +@Composable +private fun TimelineStep( + step: String, + isCompleted: Boolean, + isFirst: Boolean = false, + isLast: Boolean = false, + isError: Boolean = false, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + if (!isFirst) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(4) { + Box( + modifier = Modifier + .size(2.dp) + .clip(CircleShape) + .background( + color = if (isCompleted) KptTheme.colorScheme.primary else KptTheme.colorScheme.outline.copy(alpha = 0.3f), + ), + ) + } + } + } + + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background( + color = when { + isError -> KptTheme.colorScheme.error + isCompleted -> Color(0xFF4CAF50) + else -> KptTheme.colorScheme.outline.copy(alpha = 0.3f) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (isCompleted && !isError) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Completed", + modifier = Modifier.size(12.dp), + tint = Color.White, + ) + } else if (isError) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = "Error", + modifier = Modifier.size(12.dp), + tint = Color.White, + ) + } + } + + if (!isLast) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(4) { + Box( + modifier = Modifier + .size(2.dp) + .clip(CircleShape) + .background( + color = if (isCompleted) KptTheme.colorScheme.primary else KptTheme.colorScheme.outline.copy(alpha = 0.3f), + ), + ) + } + } + } + } + + Text( + text = step, + style = KptTheme.typography.bodyMedium, + color = if (isError) KptTheme.colorScheme.error else KptTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun DetailedInfoSection( + state: PaymentDetailsState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + InfoRow( + label = "UPI Transaction ID", + value = "123456789012", + ) + + InfoRow( + label = "To", + value = "${state.payeeName.uppercase()}\n${state.upiAppName} · ${state.upiId}", + ) + + InfoRow( + label = "From", + value = "${state.payerName.uppercase()} (${state.payerBankName})\n${state.payerUpiAppName} · ${state.payerUpiId}", + ) + + InfoRow( + label = "MifosPay Transaction ID", + value = "AbC123dEf456", + ) + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = label, + style = KptTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun BrandingSection( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = "Powered by UPI", + style = KptTheme.typography.labelLarge, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = "Mifos Pay", + style = KptTheme.typography.labelLarge, + color = KptTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} + +@Preview +@Composable +fun PaymentDetailsScreenPreview() { + PaymentDetailsScreen( + onBackClick = {}, + onPayAgainClick = {}, + onRetryClick = {}, + onShareScreenshot = {}, + transactionId = "", + ) +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsState.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsState.kt new file mode 100644 index 000000000..3d4b8ac5f --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsState.kt @@ -0,0 +1,36 @@ +/* + * 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 + +/** + * Data class representing the state of the Payment Details screen + * Contains all necessary information for displaying comprehensive payment details + */ +data class PaymentDetailsState( + val payeeName: String = "", + val isBusiness: Boolean = false, + val bankingName: String = "", + val phoneNumber: String = "", + val upiId: String = "", + val profileImageUrl: String? = null, + val amount: String = "", + val formattedAmount: String = "", + val note: String = "", + val isPaymentSuccessful: Boolean = true, + val upiTransactionId: String = "", + val payerName: String = "", + val payerBankName: String = "", + val payerAccountLast4Digits: String = "", + val payerUpiAppName: String = "", + val payerUpiId: String = "", + val upiAppName: String = "", + val mifosTransactionId: String = "", + val transactionDate: String = "", +) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsViewModel.kt new file mode 100644 index 000000000..d26ba29e3 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsViewModel.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.feature.send.money + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.mifospay.core.common.CurrencyFormatter + +/** + * ViewModel for the Payment Details screen + * Manages payment details state and provides comprehensive transaction information + * Currently uses placeholder data for demonstration purposes + */ +class PaymentDetailsViewModel : ViewModel() { + + private val _stateFlow = MutableStateFlow(createPlaceholderState()) + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + /** + * Initializes the ViewModel with transaction ID + * In a real app, this would fetch transaction details from the API + */ + fun initialize(transactionId: String) { + // TODO: Fetch transaction details from API using transactionId + // For now, we'll use placeholder data + _stateFlow.value = createPlaceholderState().copy( + upiTransactionId = transactionId, + mifosTransactionId = "MF$transactionId", + ) + } + + /** + * Creates placeholder data for demonstration purposes + * Includes sample payment details with different scenarios + */ + private fun createPlaceholderState(): PaymentDetailsState { + return PaymentDetailsState( + payeeName = "Jane Smith", + isBusiness = false, + bankingName = "TechCorp Solutions", + phoneNumber = "+91 98765 43210", + upiId = "jane.smith@upi", + profileImageUrl = null, + amount = "1,500.00", + formattedAmount = "₹1,500.00", + note = "Lunch payment", + isPaymentSuccessful = true, + upiTransactionId = "UPI123456789012345", + payerName = "Biplab Dutta", + payerBankName = "HDFC Bank", + payerAccountLast4Digits = "1234", + payerUpiAppName = "Google Pay", + payerUpiId = "biplabdutta@okicici", + upiAppName = "PhonePe", + mifosTransactionId = "MF123456789", + transactionDate = "27 Sept 2025, 2:30 PM", + ) + } + + /** + * Updates the payment status for demonstration purposes + * In a real app, this would be called when payment status changes + */ + fun updatePaymentStatus(isSuccessful: Boolean) { + val currentState = _stateFlow.value + _stateFlow.value = currentState.copy( + isPaymentSuccessful = isSuccessful, + ) + } + + /** + * Updates the payment amount for demonstration purposes + * In a real app, this would be called when amount changes + */ + fun updateAmount(newAmount: String) { + val currentState = _stateFlow.value + val formattedAmount = try { + val amountValue = newAmount.replace(",", "").toDoubleOrNull() ?: 0.0 + CurrencyFormatter.format( + balance = amountValue, + currencyCode = "INR", + maximumFractionDigits = 2, + ) + } catch (e: Exception) { + "₹$newAmount" + } + + _stateFlow.value = currentState.copy( + amount = newAmount, + formattedAmount = formattedAmount, + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentProcessingScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentProcessingScreen.kt new file mode 100644 index 000000000..0e03c6851 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentProcessingScreen.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_paid_to +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_paying_securely +import org.jetbrains.compose.resources.stringResource +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.MifosLoadingWheel +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun PaymentProcessingScreen( + onPaymentComplete: (String, String, String, String) -> Unit, + onPaymentFailed: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: PaymentProcessingViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + is PaymentProcessingEvent.PaymentComplete -> onPaymentComplete.invoke( + event.payeeName, + event.amount, + event.upiName, + event.transactionTimestamp, + ) + is PaymentProcessingEvent.PaymentFailed -> onPaymentFailed.invoke(event.errorMessage) + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + PaymentProcessingContent( + state = state, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun PaymentProcessingContent( + state: PaymentProcessingState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (state.isProcessing) { + MifosLoadingWheel( + contentDesc = "Processing Payment", + modifier = Modifier.size(120.dp), + ) + } else { + Icon( + imageVector = MifosIcons.CheckCircle, + contentDescription = "Payment Complete", + modifier = Modifier.size(120.dp), + tint = KptTheme.colorScheme.primary, + ) + } + + androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(KptTheme.spacing.xl)) + + Text( + text = if (state.isProcessing) { + stringResource( + Res.string.feature_send_money_paying_securely, + state.formattedAmount, + ) + } else { + stringResource(Res.string.feature_send_money_paid_to) + }, + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(KptTheme.spacing.md)) + + Text( + text = state.payeeName, + style = KptTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +fun PaymentProcessingScreenPreview() { + PaymentProcessingScreen( + onPaymentComplete = { _, _, _, _ -> }, + onPaymentFailed = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun PaymentProcessingContentPreview() { + val state = PaymentProcessingState( + payeeName = "John Doe", + amount = "100.00", + isProcessing = true, + ) + PaymentProcessingContent( + state = state, + modifier = Modifier.fillMaxSize(), + ) +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentProcessingViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentProcessingViewModel.kt new file mode 100644 index 000000000..00a077e5a --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentProcessingViewModel.kt @@ -0,0 +1,106 @@ +/* + * 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 kotlinx.datetime.Clock +import org.mifospay.core.ui.utils.BaseViewModel + +class PaymentProcessingViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = PaymentProcessingState(), +) { + + init { + val payeeName = savedStateHandle.get("payeeName") ?: "" + val amountInPaise = savedStateHandle.get("amount") ?: "" + val isUpiCode = savedStateHandle.get("isUpiCode") ?: false + + mutableStateFlow.update { + it.copy( + payeeName = payeeName, + amount = amountInPaise, + isUpiCode = isUpiCode, + ) + } + + startPaymentProcessing() + } + + private fun startPaymentProcessing() { + viewModelScope.launch { + try { + mutableStateFlow.update { it.copy(isProcessing = true) } + + delay(3000) + + mutableStateFlow.update { it.copy(isProcessing = false) } + + delay(1000) + + sendEvent( + PaymentProcessingEvent.PaymentComplete( + payeeName = state.payeeName, + amount = state.amount, + upiName = state.payeeName.uppercase(), + transactionTimestamp = getCurrentUnixTimestamp(), + ), + ) + } catch (e: Exception) { + sendEvent(PaymentProcessingEvent.PaymentFailed(e.message ?: "Payment failed")) + } + } + } + + override fun handleAction(action: PaymentProcessingAction) { + when (action) { + PaymentProcessingAction.RetryPayment -> { + startPaymentProcessing() + } + } + } + + /** + * Gets the current Unix timestamp from the mobile device + * This is used as a fallback when PSP timestamp is not available + */ + private fun getCurrentUnixTimestamp(): String { + return Clock.System.now().epochSeconds.toString() + } +} + +data class PaymentProcessingState( + val payeeName: String = "", + val amount: String = "", + val isUpiCode: Boolean = false, + val isProcessing: Boolean = true, +) { + val formattedAmount: String + get() = AmountUtils.formatPaiseForUI(amount) +} + +sealed interface PaymentProcessingEvent { + data class PaymentComplete( + val payeeName: String, + val amount: String, + val upiName: String, + val transactionTimestamp: String, + ) : PaymentProcessingEvent + data class PaymentFailed(val errorMessage: String) : PaymentProcessingEvent +} + +sealed interface PaymentProcessingAction { + data object RetryPayment : PaymentProcessingAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.kt new file mode 100644 index 000000000..69306830d --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.kt @@ -0,0 +1,377 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_banking_name +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_done +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_paid_to +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_payment_success +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_payment_success_description +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_powered_by_upi +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_share_screenshot +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +expect fun PaymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PaymentSuccessViewModel = koinViewModel(), +) + +@Composable +internal fun PaymentSuccessScreenDefault( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PaymentSuccessViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + PaymentSuccessEvent.ShareScreenshot -> onShareScreenshot.invoke() + PaymentSuccessEvent.NavigateToHome -> onDone.invoke() + PaymentSuccessEvent.NavigateToSendMoneyOptions -> onNavigateToSendMoneyOptions.invoke() + } + } + + MifosScaffold( + modifier = modifier, + containerColor = KptTheme.colorScheme.background, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.xxl)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + + Box( + modifier = Modifier.testTag("payment-success-content"), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + PaymentSuccessHeader(state) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + PaymentDetailsCard(state) + } + } + + Spacer(modifier = Modifier.height(KptTheme.spacing.xxl)) + + PlaceholderBanner() + + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + + Upilogo() + + Spacer(modifier = Modifier.height(KptTheme.spacing.xs)) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + ActionButtons( + onShareScreenshot = { + viewModel.trySendAction(PaymentSuccessAction.ShareScreenshot) + }, + onDone = { + viewModel.trySendAction(PaymentSuccessAction.Done) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + } + } +} + +@Composable +private fun PaymentSuccessHeader( + state: PaymentSuccessState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Icon( + imageVector = MifosIcons.CheckCircle, + contentDescription = stringResource(Res.string.feature_send_money_payment_success), + modifier = Modifier.size(100.dp), + tint = Color(0xFF4CAF50), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = state.formattedAmount, + style = KptTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(Res.string.feature_send_money_payment_success_description), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun PaymentDetailsCard( + state: PaymentSuccessState, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg), + 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), + ) { + Text( + text = stringResource(Res.string.feature_send_money_paid_to), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Text( + text = state.payeeName, + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(Res.string.feature_send_money_banking_name, state.upiName), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Text( + text = state.timestamp, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun PlaceholderBanner( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(80.dp) + .padding(horizontal = KptTheme.spacing.lg) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Advertisement Banner", + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun Upilogo( + modifier: Modifier = Modifier, +) { + Text( + text = stringResource(Res.string.feature_send_money_powered_by_upi), + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} + +@Composable +private fun ActionButtons( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + MifosOutlinedButton( + onClick = onShareScreenshot, + modifier = Modifier + .weight(2f), + text = { + Text( + text = stringResource(Res.string.feature_send_money_share_screenshot), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Share, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + ) + + MifosButton( + onClick = onDone, + modifier = Modifier + .weight(1f), + text = { + Text( + text = stringResource(Res.string.feature_send_money_done), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + }, + ) + } +} + +@Preview +@Composable +fun PaymentSuccessScreenPreview() { + PaymentSuccessScreen( + onShareScreenshot = {}, + onDone = {}, + onNavigateToSendMoneyOptions = {}, + modifier = Modifier, + ) +} + +@Preview +@Composable +fun PaymentSuccessHeaderPreview() { + val state = PaymentSuccessState( + payeeName = "John Doe", + amount = "100.00", + upiName = "JOHN DOE", + timestamp = "14 August 2025, 11:09 am", + ) + PaymentSuccessHeader(state = state, modifier = Modifier) +} + +@Preview +@Composable +fun PaymentDetailsCardPreview() { + val state = PaymentSuccessState( + payeeName = "John Doe", + amount = "100.00", + upiName = "JOHN DOE", + timestamp = "14 August 2025, 11:09 am", + ) + PaymentDetailsCard(state = state, modifier = Modifier) +} + +@Preview +@Composable +fun PlaceholderBannerPreview() { + PlaceholderBanner(modifier = Modifier) +} + +@Preview +@Composable +fun UpilogoPreview() { + Upilogo(modifier = Modifier) +} + +@Preview +@Composable +fun ActionButtonsPreview() { + ActionButtons( + onShareScreenshot = {}, + onDone = {}, + modifier = Modifier, + ) +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessViewModel.kt new file mode 100644 index 000000000..cb7dd1770 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessViewModel.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.flow.update +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.mifospay.core.common.DateHelper +import org.mifospay.core.ui.utils.BaseViewModel + +class PaymentSuccessViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = PaymentSuccessState(), +) { + + init { + val payeeName = savedStateHandle.get("payeeName") ?: "" + val amountInPaise = savedStateHandle.get("amount") ?: "" + val upiName = savedStateHandle.get("upiName") ?: payeeName + val transactionTimestamp = savedStateHandle.get("transactionTimestamp") // Unix timestamp from PSP or mobile device + val timestamp = if (transactionTimestamp != null) { + formatTransactionTimestamp(transactionTimestamp) + } else { + getCurrentTimestamp() + } + + mutableStateFlow.update { + it.copy( + payeeName = payeeName, + amount = amountInPaise, + upiName = upiName, + timestamp = timestamp, + ) + } + } + + override fun handleAction(action: PaymentSuccessAction) { + when (action) { + PaymentSuccessAction.ShareScreenshot -> { + sendEvent(PaymentSuccessEvent.ShareScreenshot) + } + PaymentSuccessAction.Done -> { + sendEvent(PaymentSuccessEvent.NavigateToSendMoneyOptions) + } + } + } + + /** + * Formats Unix timestamp from PSP or mobile device into readable format + * Falls back to current device timestamp if parsing fails + */ + private fun formatTransactionTimestamp(unixTimestamp: String): String { + return try { + val timestamp = unixTimestamp.toLong() + val instant = Instant.fromEpochSeconds(timestamp) + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + + val day = localDateTime.dayOfMonth + val month = localDateTime.month.name.lowercase().capitalize() + val year = localDateTime.year + val hour = localDateTime.hour.toString().padStart(2, '0') + val minute = localDateTime.minute.toString().padStart(2, '0') + val amPm = if (localDateTime.hour < 12) "am" else "pm" + + "$day $month $year, $hour:$minute $amPm" + } catch (e: Exception) { + // Fallback to current timestamp if parsing fails + getCurrentTimestamp() + } + } + + private fun getCurrentTimestamp(): String { + val currentDateTime = DateHelper.currentDate + val day = currentDateTime.dayOfMonth + val month = currentDateTime.month.name.lowercase().capitalize() + val year = currentDateTime.year + val hour = currentDateTime.hour.toString().padStart(2, '0') + val minute = currentDateTime.minute.toString().padStart(2, '0') + val amPm = if (currentDateTime.hour < 12) "am" else "pm" + + return "$day $month $year, $hour:$minute $amPm" + } +} + +// amount stored in paise +data class PaymentSuccessState( + val payeeName: String = "", + val amount: String = "", + val upiName: String = "", + val timestamp: String = "", +) { + val formattedAmount: String + get() = AmountUtils.formatPaiseForUI(amount) +} + +sealed interface PaymentSuccessEvent { + data object ShareScreenshot : PaymentSuccessEvent + data object NavigateToHome : PaymentSuccessEvent + data object NavigateToSendMoneyOptions : PaymentSuccessEvent +} + +sealed interface PaymentSuccessAction { + data object ShareScreenshot : PaymentSuccessAction + data object Done : PaymentSuccessAction +} 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..ebc6b8055 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,642 @@ +/* + * 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.common.CurrencyFormatter +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, + onPaymentHistoryClick: () -> Unit, + onUpiTransactionHistoryClick: () -> 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( + onPaymentHistoryClick = onPaymentHistoryClick, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + MerchantsSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + TransactionHistorySection( + onSeeAllClick = onUpiTransactionHistoryClick, + ) + + 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( + onPaymentHistoryClick: () -> Unit, + 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", + onClick = onPaymentHistoryClick, + 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 TransactionHistorySection( + onSeeAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val dummyTransactions = listOf( + TransactionItem( + payeeName = "Rahul Sharma", + amount = 2500.0, + date = "15 August", + profileImageUrl = null, + ), + TransactionItem( + payeeName = "Priya Patel", + amount = 1800.0, + date = "14 August", + profileImageUrl = null, + ), + TransactionItem( + payeeName = "Amit Kumar", + amount = 3200.0, + date = "13 August", + profileImageUrl = null, + ), + TransactionItem( + payeeName = "Neha Singh", + amount = 950.0, + date = "12 August", + profileImageUrl = null, + ), + TransactionItem( + payeeName = "Vikram Mehta", + amount = 4100.0, + date = "11 August", + profileImageUrl = null, + ), + ) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "UPI Transaction History", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Row( + modifier = Modifier.clickable { onSeeAllClick() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = "See All", + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.primary, + ) + Icon( + imageVector = MifosIcons.ChevronRight, + contentDescription = "See All", + modifier = Modifier.size(16.dp), + tint = KptTheme.colorScheme.primary, + ) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + dummyTransactions.forEach { transaction -> + TransactionItemRow(transaction = transaction) + } + } + } +} + +@Composable +private fun TransactionItemRow( + transaction: TransactionItem, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (transaction.profileImageUrl != null) { + Text( + text = transaction.payeeName.take(1).uppercase(), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } else { + Text( + text = transaction.payeeName.take(1).uppercase(), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = transaction.payeeName.uppercase(), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + Text( + text = transaction.date, + style = KptTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = CurrencyFormatter.format( + balance = transaction.amount, + currencyCode = "INR", + maximumFractionDigits = 2, + ), + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + } +} + +data class TransactionItem( + val payeeName: String, + val amount: Double, + val date: String, + val profileImageUrl: String?, +) + +@Composable +private fun PersonItem( + name: String, + isMoreButton: Boolean = false, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable(enabled = onClick != null) { onClick?.invoke() } + .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 635df3c12..64863fc9c 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 @@ -32,16 +32,19 @@ import mobile_wallet.feature.send_money.generated.resources.feature_send_money_e import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_invalid_amount import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_but_found import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_data_missing +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_upi_qr_parsed_successfully import org.jetbrains.compose.resources.StringResource import org.mifospay.core.common.DataState import org.mifospay.core.common.StringResourceSerializer 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( @@ -264,6 +282,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/UpiPinScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiPinScreen.kt new file mode 100644 index 000000000..ce43d0f51 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiPinScreen.kt @@ -0,0 +1,527 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +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 kotlinx.serialization.Serializable +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.designsystem.theme.MifosTheme +import template.core.base.designsystem.theme.KptTheme + +/** + * Data class representing payment details for UPI transactions + * @property payeeName Name of the payee/recipient + * @property bankName Name of the bank + * @property accountNumber Account number of the payee + * @property amount Payment amount + * @property refId Reference ID for the transaction + */ +@Serializable +data class PaymentDetails( + val payeeName: String, + val bankName: String, + val accountNumber: String, + val amount: Double, + val refId: String, +) + +@Composable +fun UpiPinScreen( + onBackClick: () -> Unit, + onUpiPinEntered: (String, String, String, Boolean) -> Unit, + modifier: Modifier = Modifier, + viewModel: UpiPinViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val isDropdownExpanded = rememberSaveable { mutableStateOf(false) } + + MifosScaffold( + modifier = modifier.fillMaxSize(), + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(color = androidx.compose.ui.graphics.Color.White), + ) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + UpiPinHeader( + bankAccountDetails = state.paymentDetails, + modifier = Modifier.padding( + top = KptTheme.spacing.xl, + start = KptTheme.spacing.lg, + end = KptTheme.spacing.lg, + ), + ) + + PayeeTransactionDetails( + payeeName = state.paymentDetails.payeeName, + amount = state.paymentDetails.amount, + refId = state.paymentDetails.refId, + accountNumber = state.paymentDetails.accountNumber, + isDropdownExpanded = isDropdownExpanded, + modifier = Modifier.padding(horizontal = KptTheme.spacing.lg), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + + UpiPinContent( + bankAccountDetails = state.paymentDetails, + onUpiPinEntered = { pin -> + viewModel.trySendAction(UpiPinAction.UpiPinEntered(pin)) + onUpiPinEntered( + state.paymentDetails.payeeName, + state.amount.toString(), + pin, + state.isUpiCode, + ) + }, + modifier = Modifier.padding(horizontal = KptTheme.spacing.lg), + ) + } + + if (isDropdownExpanded.value) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .offset(y = 200.dp) + .padding(horizontal = KptTheme.spacing.lg), + colors = CardDefaults.cardColors( + containerColor = androidx.compose.ui.graphics.Color.White, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = KptTheme.elevation.level3, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "REF ID:", + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Gray, + modifier = Modifier.width(80.dp), + ) + Text( + text = state.paymentDetails.refId, + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Black, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "ACCOUNT:", + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Gray, + modifier = Modifier.width(80.dp), + ) + Text( + text = maskAccountNumberForDropdown(state.paymentDetails.accountNumber), + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Black, + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .offset(y = 200.dp + 100.dp) + .height(1000.dp) + .background( + color = KptTheme.colorScheme.surface.copy(alpha = 0.7f), + ), + ) + } + } + } + } + } +} + +@Composable +private fun UpiPinHeader( + bankAccountDetails: PaymentDetails?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = bankAccountDetails?.bankName ?: "Bank Name", + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Black, + ) + + Text( + text = maskAccountNumber(bankAccountDetails?.accountNumber ?: "1234567890123456"), + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Gray, + ) + } + + Text( + text = "UPI", + style = KptTheme.typography.titleLarge, + color = androidx.compose.ui.graphics.Color.Black, + ) + } +} + +@Composable +private fun UpiPinContent( + bankAccountDetails: PaymentDetails?, + onUpiPinEntered: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val upiPin = rememberSaveable { mutableStateOf("") } + val errorMessage = rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val showPin = rememberSaveable { mutableStateOf(false) } + val previousPinLength = rememberSaveable { mutableStateOf(0) } + + val pinLength = getPinLengthForBank(bankAccountDetails?.bankName) + val expectedPin = getExpectedPinForBank(bankAccountDetails?.bankName) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + LaunchedEffect(errorMessage.value) { + if (errorMessage.value.isNotEmpty()) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(upiPin.value) { + // Only show PIN briefly when adding characters, not when deleting + if (upiPin.value.length > previousPinLength.value) { + showPin.value = true + kotlinx.coroutines.delay(200) // Show PIN for 200ms then mask + showPin.value = false + } + previousPinLength.value = upiPin.value.length + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = androidx.compose.ui.graphics.Color.White, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = KptTheme.elevation.level1, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Text( + text = "Enter UPI PIN", + style = KptTheme.typography.headlineMedium, + color = androidx.compose.ui.graphics.Color.Black, + ) + + if (errorMessage.value.isNotEmpty()) { + Text( + text = errorMessage.value, + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Red, + ) + } + + BasicTextField( + value = upiPin.value, + onValueChange = { + if (it.length <= pinLength) { + upiPin.value = it + errorMessage.value = "" + } + }, + keyboardActions = KeyboardActions( + onDone = { + if (upiPin.value.length == pinLength) { + errorMessage.value = "" + + if (upiPin.value == expectedPin) { + onUpiPinEntered(upiPin.value) + } else { + errorMessage.value = "Incorrect UPI PIN. Please try again." + upiPin.value = "" + } + } + }, + ), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + decorationBox = { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth(), + ) { + repeat(pinLength) { index -> + UpiPinCharView( + index = index, + text = upiPin.value, + showPin = showPin.value, + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + Text( + text = "UPI PIN will keep your account secure from unauthorized access. Do not share this PIN with anyone", + style = KptTheme.typography.bodySmall, + color = androidx.compose.ui.graphics.Color.Gray, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun UpiPinCharView( + index: Int, + text: String, + showPin: Boolean = false, +) { + val isFocused = text.length == index + + val char = when { + index >= text.length -> "—" + else -> if (showPin && index == text.length - 1) text[index].toString() else "•" + } + + Text( + modifier = Modifier + .width(56.dp) + .wrapContentHeight(align = Alignment.CenterVertically), + text = char, + style = KptTheme.typography.headlineLarge.copy( + fontSize = 40.sp, + ), + color = if (isFocused) { + androidx.compose.ui.graphics.Color.Blue + } else { + androidx.compose.ui.graphics.Color.Black + }, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun PayeeTransactionDetails( + payeeName: String, + amount: Double, + refId: String, + accountNumber: String, + isDropdownExpanded: androidx.compose.runtime.MutableState, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = androidx.compose.ui.graphics.Color.White, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = KptTheme.elevation.level1, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = androidx.compose.ui.graphics.Color.LightGray.copy(alpha = 0.3f), + ) + .padding(KptTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = "To:", + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Gray, + ) + Text( + text = payeeName.uppercase(), + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Black, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = "Sending:", + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Gray, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = AmountUtils.formatRupeesForUI(amount.toString()), + style = KptTheme.typography.bodyMedium, + color = androidx.compose.ui.graphics.Color.Black, + ) + IconButton( + onClick = { isDropdownExpanded.value = !isDropdownExpanded.value }, + ) { + Icon( + imageVector = if (isDropdownExpanded.value) { + MifosIcons.DropUp + } else { + MifosIcons.DropDown + }, + contentDescription = if (isDropdownExpanded.value) { + "Hide transaction details" + } else { + "Show transaction details" + }, + tint = androidx.compose.ui.graphics.Color.Black, + ) + } + } + } + } + } +} + +private fun maskAccountNumber(accountNumber: String): String { + return if (accountNumber.length >= 8) { + "XXXX${accountNumber.takeLast(4)}" + } else { + accountNumber + } +} + +private fun maskAccountNumberForDropdown(accountNumber: String): String { + return if (accountNumber.length >= 8) { + "XXXXXX${accountNumber.takeLast(4)}" + } else { + accountNumber + } +} + +private fun getPinLengthForBank(bankName: String?): Int { + return when (bankName) { + "Sample Bank" -> 4 + "Test Bank" -> 6 + else -> 4 + } +} + +private fun getExpectedPinForBank(bankName: String?): String { + return when (bankName) { + "Sample Bank" -> "1234" + "Test Bank" -> "123456" + else -> "1234" + } +} + +@Preview +@Composable +private fun UpiPinScreenPreview() { + MifosTheme { + UpiPinScreen( + onBackClick = { }, + onUpiPinEntered = { payeeName, amount, pin, isUpiCode -> + // Preview callback + }, + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiPinViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiPinViewModel.kt new file mode 100644 index 000000000..c9f51239b --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiPinViewModel.kt @@ -0,0 +1,97 @@ +/* + * 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.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.ui.utils.BaseViewModel + +class UpiPinViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: run { + val payeeName = requireNotNull(savedStateHandle.get("payeeName")) + val amountInPaise = requireNotNull(savedStateHandle.get("amount")) + val isUpiCode = requireNotNull(savedStateHandle.get("isUpiCode")) + val bankName = requireNotNull(savedStateHandle.get("bankName")) + val accountNo = requireNotNull(savedStateHandle.get("accountNo")) + + val refId = generateRefId() + + val amountInRupees = if (amountInPaise.isNotEmpty()) { + AmountUtils.paiseToRupees(amountInPaise).toDoubleOrNull() ?: 0.0 + } else { + 0.0 + } + + val paymentDetails = PaymentDetails( + payeeName = payeeName, + bankName = bankName, + accountNumber = accountNo, + amount = amountInRupees, + refId = refId, + ) + + UpiPinState( + paymentDetails = paymentDetails, + isUpiCode = isUpiCode, + amount = amountInRupees, + refId = refId, + ) + }, +) { + + companion object { + private const val KEY_STATE = "upi_pin_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + } + + override fun handleAction(action: UpiPinAction) { + when (action) { + is UpiPinAction.UpiPinEntered -> { + sendEvent(UpiPinEvent.OnUpiPinEntered(action.pin)) + } + } + } +} + +/** + * Generates a unique 36-character alphanumeric reference ID for the transaction + */ +private fun generateRefId(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return (1..36).map { chars.random() }.joinToString("") +} + +@Serializable +data class UpiPinState( + val paymentDetails: PaymentDetails, + val amount: Double, + val refId: String, + val isUpiCode: Boolean, +) + +sealed interface UpiPinEvent { + data class OnUpiPinEntered(val pin: String) : UpiPinEvent +} + +sealed interface UpiPinAction { + data class UpiPinEntered(val pin: String) : UpiPinAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiTransactionHistoryScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiTransactionHistoryScreen.kt new file mode 100644 index 000000000..5f0a0cfa9 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiTransactionHistoryScreen.kt @@ -0,0 +1,337 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.LocalDate +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun UpiTransactionHistoryScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: UpiTransactionHistoryViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + UpiTransactionHistoryEvent.NavigateBack -> { + onBackClick.invoke() + } + } + } + + MifosScaffold( + modifier = modifier, + containerColor = KptTheme.colorScheme.background, + topBar = { + UpiTransactionHistoryTopBar( + searchQuery = state.searchQuery, + onSearchQueryChange = { query -> + viewModel.trySendAction(UpiTransactionHistoryAction.SearchQueryChanged(query)) + }, + onSearch = { + viewModel.trySendAction(UpiTransactionHistoryAction.SearchPerformed) + }, + onBackClick = { + viewModel.trySendAction(UpiTransactionHistoryAction.BackPressed) + }, + onClearSearch = { + viewModel.trySendAction(UpiTransactionHistoryAction.ClearSearch) + }, + ) + }, + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Loading transactions...", + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurface, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .padding(top = KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + items(state.groupedTransactions) { monthGroup -> + MonthTransactionGroup( + monthGroup = monthGroup, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UpiTransactionHistoryTopBar( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSearch: () -> Unit, + onBackClick: () -> Unit, + onClearSearch: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = KptTheme.colorScheme.surface, + tonalElevation = 4.dp, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.md, vertical = KptTheme.spacing.sm), + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.align(Alignment.CenterStart), + ) { + Icon( + imageVector = MifosIcons.ArrowBack, + contentDescription = "Back", + tint = KptTheme.colorScheme.onSurface, + ) + } + + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier + .fillMaxWidth(0.7f) + .align(Alignment.Center) + .onKeyEvent { keyEvent -> + if (keyEvent.key == Key.Enter) { + onSearch() + true + } else { + false + } + }, + placeholder = { + Text( + text = "Search transactions...", + color = KptTheme.colorScheme.onSurfaceVariant, + ) + }, + singleLine = true, + shape = RoundedCornerShape(32.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = KptTheme.colorScheme.primary, + unfocusedBorderColor = KptTheme.colorScheme.outline, + ), + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton( + onClick = onClearSearch, + ) { + Icon( + imageVector = MifosIcons.Close, + contentDescription = "Clear search", + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) + } + } +} + +@Composable +private fun MonthTransactionGroup( + monthGroup: MonthTransactionGroup, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = monthGroup.monthYear, + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + + Text( + text = CurrencyFormatter.format( + balance = monthGroup.totalAmount, + currencyCode = "INR", + maximumFractionDigits = 2, + ), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + monthGroup.transactions.forEach { transaction -> + TransactionHistoryCard( + transaction = transaction, + ) + } + } + } +} + +@Composable +private fun TransactionHistoryCard( + transaction: UpiTransaction, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (transaction.profileImageUrl != null) { + Text( + text = transaction.payeeName.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } else { + Text( + text = transaction.payeeName.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = transaction.payeeName.uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ) + Text( + text = transaction.formattedDate, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = CurrencyFormatter.format( + balance = transaction.amount, + currencyCode = "INR", + maximumFractionDigits = 2, + ), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + } + } +} + +data class UpiTransaction( + val id: String, + val payeeName: String, + val amount: Double, + val date: LocalDate, + val profileImageUrl: String?, +) { + val formattedDate: String + get() = "${date.dayOfMonth} ${date.month.name.lowercase().replaceFirstChar { it.uppercase() }}" +} + +data class MonthTransactionGroup( + val monthYear: String, + val totalAmount: Double, + val transactions: List, +) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiTransactionHistoryViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiTransactionHistoryViewModel.kt new file mode 100644 index 000000000..ef4379e13 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/UpiTransactionHistoryViewModel.kt @@ -0,0 +1,278 @@ +/* + * 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 co.touchlab.kermit.Logger +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.minus +import org.mifospay.core.common.DateHelper +import org.mifospay.core.ui.utils.BaseViewModel + +class UpiTransactionHistoryViewModel() : BaseViewModel( + initialState = UpiTransactionHistoryState(), +) { + + private val dummyTransactions = generateDummyTransactions() + + override fun handleAction(action: UpiTransactionHistoryAction) { + Logger.d("UPI_TRANSACTION_HISTORY UpiTransactionHistoryViewModel Action: $action") + when (action) { + UpiTransactionHistoryAction.NavigateBack -> { + sendEvent(UpiTransactionHistoryEvent.NavigateBack) + } + is UpiTransactionHistoryAction.SearchQueryChanged -> { + mutableStateFlow.value = mutableStateFlow.value.copy(searchQuery = action.query) + } + UpiTransactionHistoryAction.SearchPerformed -> { + performSearch() + } + UpiTransactionHistoryAction.ClearSearch -> { + clearSearch() + } + UpiTransactionHistoryAction.BackPressed -> { + handleBackPressed() + } + } + } + + private fun performSearch() { + val query = mutableStateFlow.value.searchQuery.trim() + Logger.d("UPI_TRANSACTION_HISTORY UpiTransactionHistoryViewModel Performing search with query: $query") + + val filteredTransactions = if (query.isEmpty()) { + dummyTransactions + } else { + dummyTransactions.filter { transaction -> + transaction.payeeName.contains(query, ignoreCase = true) + } + } + + val groupedTransactions = groupTransactionsByMonth(filteredTransactions) + mutableStateFlow.value = mutableStateFlow.value.copy( + groupedTransactions = groupedTransactions, + isLoading = false, + isSearchActive = query.isNotEmpty(), + ) + } + + private fun clearSearch() { + Logger.d("UPI_TRANSACTION_HISTORY UpiTransactionHistoryViewModel Clearing search") + val groupedTransactions = groupTransactionsByMonth(dummyTransactions) + mutableStateFlow.value = mutableStateFlow.value.copy( + searchQuery = "", + groupedTransactions = groupedTransactions, + isSearchActive = false, + ) + } + + private fun handleBackPressed() { + val currentState = mutableStateFlow.value + if (currentState.isSearchActive) { + Logger.d("UPI_TRANSACTION_HISTORY UpiTransactionHistoryViewModel Back pressed - clearing search") + clearSearch() + } else { + Logger.d("UPI_TRANSACTION_HISTORY UpiTransactionHistoryViewModel Back pressed - navigating back") + sendEvent(UpiTransactionHistoryEvent.NavigateBack) + } + } + + private fun groupTransactionsByMonth(transactions: List): List { + return transactions + .groupBy { transaction -> + "${transaction.date.month.name.lowercase().replaceFirstChar { it.uppercase() }} ${transaction.date.year}" + } + .map { (monthYear, monthTransactions) -> + val totalAmount = monthTransactions.sumOf { it.amount } + MonthTransactionGroup( + monthYear = monthYear, + totalAmount = totalAmount, + transactions = monthTransactions.sortedByDescending { it.date }, + ) + } + .sortedByDescending { group -> + group.transactions.firstOrNull()?.date + } + } + + private fun generateDummyTransactions(): List { + val currentDateTime = DateHelper.currentDate + val currentDate = LocalDate(currentDateTime.year, currentDateTime.month, currentDateTime.dayOfMonth) + return listOf( + UpiTransaction( + id = "1", + payeeName = "Rahul Sharma", + amount = 2500.0, + date = currentDate - DatePeriod(days = 1), + profileImageUrl = null, + ), + UpiTransaction( + id = "2", + payeeName = "Priya Patel", + amount = 1800.0, + date = currentDate - DatePeriod(days = 2), + profileImageUrl = null, + ), + UpiTransaction( + id = "3", + payeeName = "Amit Kumar", + amount = 3200.0, + date = currentDate - DatePeriod(days = 3), + profileImageUrl = null, + ), + UpiTransaction( + id = "4", + payeeName = "Neha Singh", + amount = 950.0, + date = currentDate - DatePeriod(days = 4), + profileImageUrl = null, + ), + UpiTransaction( + id = "5", + payeeName = "Vikram Mehta", + amount = 4100.0, + date = currentDate - DatePeriod(days = 5), + profileImageUrl = null, + ), + UpiTransaction( + id = "6", + payeeName = "Sneha Gupta", + amount = 1200.0, + date = currentDate - DatePeriod(days = 6), + profileImageUrl = null, + ), + UpiTransaction( + id = "7", + payeeName = "Rajesh Verma", + amount = 2800.0, + date = currentDate - DatePeriod(days = 7), + profileImageUrl = null, + ), + UpiTransaction( + id = "8", + payeeName = "Anita Joshi", + amount = 1500.0, + date = currentDate - DatePeriod(days = 8), + profileImageUrl = null, + ), + UpiTransaction( + id = "9", + payeeName = "Deepak Yadav", + amount = 3600.0, + date = currentDate - DatePeriod(days = 9), + profileImageUrl = null, + ), + UpiTransaction( + id = "10", + payeeName = "Pooja Agarwal", + amount = 2200.0, + date = currentDate - DatePeriod(days = 10), + profileImageUrl = null, + ), + UpiTransaction( + id = "11", + payeeName = "Ravi Tiwari", + amount = 1900.0, + date = currentDate - DatePeriod(days = 15), + profileImageUrl = null, + ), + UpiTransaction( + id = "12", + payeeName = "Sunita Reddy", + amount = 3100.0, + date = currentDate - DatePeriod(days = 16), + profileImageUrl = null, + ), + UpiTransaction( + id = "13", + payeeName = "Manoj Singh", + amount = 1400.0, + date = currentDate - DatePeriod(days = 17), + profileImageUrl = null, + ), + UpiTransaction( + id = "14", + payeeName = "Kavita Sharma", + amount = 2700.0, + date = currentDate - DatePeriod(days = 18), + profileImageUrl = null, + ), + UpiTransaction( + id = "15", + payeeName = "Suresh Kumar", + amount = 3300.0, + date = currentDate - DatePeriod(days = 19), + profileImageUrl = null, + ), + UpiTransaction( + id = "16", + payeeName = "Meera Patel", + amount = 1600.0, + date = currentDate - DatePeriod(days = 20), + profileImageUrl = null, + ), + UpiTransaction( + id = "17", + payeeName = "Arjun Malhotra", + amount = 2900.0, + date = currentDate - DatePeriod(days = 25), + profileImageUrl = null, + ), + UpiTransaction( + id = "18", + payeeName = "Divya Iyer", + amount = 2100.0, + date = currentDate - DatePeriod(days = 26), + profileImageUrl = null, + ), + UpiTransaction( + id = "19", + payeeName = "Nikhil Nair", + amount = 3800.0, + date = currentDate - DatePeriod(days = 27), + profileImageUrl = null, + ), + UpiTransaction( + id = "20", + payeeName = "Shruti Desai", + amount = 1700.0, + date = currentDate - DatePeriod(days = 28), + profileImageUrl = null, + ), + ) + } + + init { + val groupedTransactions = groupTransactionsByMonth(dummyTransactions) + mutableStateFlow.value = mutableStateFlow.value.copy( + groupedTransactions = groupedTransactions, + isLoading = false, + ) + } +} + +data class UpiTransactionHistoryState( + val isLoading: Boolean = true, + val searchQuery: String = "", + val groupedTransactions: List = emptyList(), + val isSearchActive: Boolean = false, +) + +sealed class UpiTransactionHistoryAction { + object NavigateBack : UpiTransactionHistoryAction() + data class SearchQueryChanged(val query: String) : UpiTransactionHistoryAction() + object SearchPerformed : UpiTransactionHistoryAction() + object ClearSearch : UpiTransactionHistoryAction() + object BackPressed : UpiTransactionHistoryAction() +} + +sealed class UpiTransactionHistoryEvent { + object NavigateBack : UpiTransactionHistoryEvent() +} 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..cf1143e5f 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,26 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.send.money.PayeeDetailsViewModel +import org.mifospay.feature.send.money.PaymentChatHistoryViewModel +import org.mifospay.feature.send.money.PaymentDetailsViewModel +import org.mifospay.feature.send.money.PaymentProcessingViewModel +import org.mifospay.feature.send.money.PaymentSuccessViewModel import org.mifospay.feature.send.money.ScannerModule +import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel +import org.mifospay.feature.send.money.UpiPinViewModel +import org.mifospay.feature.send.money.UpiTransactionHistoryViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + viewModelOf(::SendMoneyOptionsViewModel) + viewModelOf(::PayeeDetailsViewModel) + viewModelOf(::PaymentProcessingViewModel) + viewModelOf(::PaymentSuccessViewModel) + viewModelOf(::UpiPinViewModel) + viewModelOf(::PaymentChatHistoryViewModel) + viewModelOf(::PaymentDetailsViewModel) + viewModelOf(::UpiTransactionHistoryViewModel) } 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 99d1fc012..423f2d2c3 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -16,20 +16,160 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.PayeeDetailsScreen +import org.mifospay.feature.send.money.PayeeDetailsState +import org.mifospay.feature.send.money.PaymentChatHistoryScreen +import org.mifospay.feature.send.money.PaymentDetailsScreen +import org.mifospay.feature.send.money.PaymentProcessingScreen +import org.mifospay.feature.send.money.PaymentSuccessScreen +import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen +import org.mifospay.feature.send.money.UpiPinScreen +import org.mifospay.feature.send.money.UpiTransactionHistoryScreen const val SEND_MONEY_ROUTE = "send_money_route" 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 UPI_PIN_ROUTE = "upi_pin_route" +const val UPI_PIN_PAYEE_NAME_ARG = "payeeName" +const val UPI_PIN_AMOUNT_ARG = "amount" +const val UPI_PIN_IS_UPI_ARG = "isUpiCode" +const val UPI_PIN_BANK_NAME_ARG = "bankName" +const val UPI_PIN_ACCOUNT_NO_ARG = "accountNo" +const val UPI_PIN_REF_ID_ARG = "refId" + +const val UPI_PIN_BASE_ROUTE = "$UPI_PIN_ROUTE?$UPI_PIN_PAYEE_NAME_ARG={$UPI_PIN_PAYEE_NAME_ARG}&$UPI_PIN_AMOUNT_ARG={$UPI_PIN_AMOUNT_ARG}&$UPI_PIN_REF_ID_ARG={$UPI_PIN_REF_ID_ARG}&$UPI_PIN_IS_UPI_ARG={$UPI_PIN_IS_UPI_ARG}&$UPI_PIN_BANK_NAME_ARG={$UPI_PIN_BANK_NAME_ARG}&$UPI_PIN_ACCOUNT_NO_ARG={$UPI_PIN_ACCOUNT_NO_ARG}" + +const val PAYMENT_PROCESSING_ROUTE = "payment_processing_route" +const val PAYMENT_PROCESSING_PAYEE_NAME_ARG = "payeeName" +const val PAYMENT_PROCESSING_AMOUNT_ARG = "amount" +const val PAYMENT_PROCESSING_IS_UPI_ARG = "isUpiCode" + +const val PAYMENT_PROCESSING_BASE_ROUTE = "$PAYMENT_PROCESSING_ROUTE?$PAYMENT_PROCESSING_PAYEE_NAME_ARG={$PAYMENT_PROCESSING_PAYEE_NAME_ARG}&$PAYMENT_PROCESSING_AMOUNT_ARG={$PAYMENT_PROCESSING_AMOUNT_ARG}&$PAYMENT_PROCESSING_IS_UPI_ARG={$PAYMENT_PROCESSING_IS_UPI_ARG}" + +const val PAYMENT_SUCCESS_ROUTE = "payment_success_route" +const val PAYMENT_SUCCESS_PAYEE_NAME_ARG = "payeeName" +const val PAYMENT_SUCCESS_AMOUNT_ARG = "amount" +const val PAYMENT_SUCCESS_UPI_NAME_ARG = "upiName" +const val PAYMENT_SUCCESS_TRANSACTION_TIMESTAMP_ARG = "transactionTimestamp" + +const val PAYMENT_SUCCESS_BASE_ROUTE = "$PAYMENT_SUCCESS_ROUTE?$PAYMENT_SUCCESS_PAYEE_NAME_ARG={$PAYMENT_SUCCESS_PAYEE_NAME_ARG}&$PAYMENT_SUCCESS_AMOUNT_ARG={$PAYMENT_SUCCESS_AMOUNT_ARG}&$PAYMENT_SUCCESS_UPI_NAME_ARG={$PAYMENT_SUCCESS_UPI_NAME_ARG}&$PAYMENT_SUCCESS_TRANSACTION_TIMESTAMP_ARG={$PAYMENT_SUCCESS_TRANSACTION_TIMESTAMP_ARG}" + +const val PAYMENT_CHAT_HISTORY_ROUTE = "payment_chat_history_route" +const val UPI_TRANSACTION_HISTORY_ROUTE = "upi_transaction_history_route" +const val PAYMENT_DETAILS_ROUTE = "payment_details_route" +const val PAYMENT_DETAILS_TRANSACTION_ID_ARG = "transactionId" +const val PAYMENT_DETAILS_BASE_ROUTE = "$PAYMENT_DETAILS_ROUTE/{$PAYMENT_DETAILS_TRANSACTION_ID_ARG}" + +fun NavController.navigateToPaymentDetailsScreen( + transactionId: String, + navOptions: NavOptions? = null, +) { + val route = "$PAYMENT_DETAILS_ROUTE/$transactionId" + val options = navOptions ?: navOptions { + popUpTo(PAYMENT_CHAT_HISTORY_ROUTE) { inclusive = false } + } + navigate(route, options) +} + 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) +} + +// Expected to be in paise +fun NavController.navigateToUpiPinScreen( + payeeName: String, + amount: String, + refId: String, + isUpiCode: Boolean, + bankName: String, + accountNo: String, + navOptions: NavOptions? = null, +) { + val encodedPayeeName = payeeName.urlEncode() + val encodedAmount = amount.urlEncode() + val encodedRefId = refId.urlEncode() + val encodedBankName = bankName.urlEncode() + val encodedAccountNo = accountNo.urlEncode() + val route = "$UPI_PIN_ROUTE?$UPI_PIN_PAYEE_NAME_ARG=$encodedPayeeName&$UPI_PIN_AMOUNT_ARG=$encodedAmount&$UPI_PIN_REF_ID_ARG=$encodedRefId&$UPI_PIN_IS_UPI_ARG=$isUpiCode&$UPI_PIN_BANK_NAME_ARG=$encodedBankName&$UPI_PIN_ACCOUNT_NO_ARG=$encodedAccountNo" + val options = navOptions ?: navOptions { + popUpTo(PAYEE_DETAILS_ROUTE) { inclusive = false } + } + navigate(route, options) +} + +// amount in paise +fun NavController.navigateToPaymentProcessingScreen( + payeeName: String, + amount: String, + isUpiCode: Boolean, + navOptions: NavOptions? = null, +) { + val encodedPayeeName = payeeName.urlEncode() + val encodedAmount = amount.urlEncode() + val route = "$PAYMENT_PROCESSING_ROUTE?$PAYMENT_PROCESSING_PAYEE_NAME_ARG=$encodedPayeeName&$PAYMENT_PROCESSING_AMOUNT_ARG=$encodedAmount&$PAYMENT_PROCESSING_IS_UPI_ARG=$isUpiCode" + val options = navOptions ?: navOptions { + popUpTo(UPI_PIN_ROUTE) { inclusive = true } + } + navigate(route, options) +} + +// Expected to be in paise +fun NavController.navigateToPaymentSuccessScreen( + payeeName: String, + amount: String, + upiName: String, + transactionTimestamp: String, + navOptions: NavOptions? = null, +) { + val encodedPayeeName = payeeName.urlEncode() + val encodedAmount = amount.urlEncode() + val encodedUpiName = upiName.urlEncode() + val encodedTransactionTimestamp = transactionTimestamp.urlEncode() + val route = "$PAYMENT_SUCCESS_ROUTE?$PAYMENT_SUCCESS_PAYEE_NAME_ARG=$encodedPayeeName&$PAYMENT_SUCCESS_AMOUNT_ARG=$encodedAmount&$PAYMENT_SUCCESS_UPI_NAME_ARG=$encodedUpiName&$PAYMENT_SUCCESS_TRANSACTION_TIMESTAMP_ARG=$encodedTransactionTimestamp" + val options = navOptions ?: navOptions { + popUpTo(PAYMENT_PROCESSING_ROUTE) { inclusive = true } + } + navigate(route, options) +} + +fun NavController.navigateToPaymentChatHistoryScreen( + navOptions: NavOptions? = null, +) = navigate(PAYMENT_CHAT_HISTORY_ROUTE, navOptions) + +fun NavController.navigateToUpiTransactionHistoryScreen( + navOptions: NavOptions? = null, +) = navigate(UPI_TRANSACTION_HISTORY_ROUTE, navOptions) + fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, navigateToScanQrScreen: () -> Unit, ) { composableWithSlideTransitions( @@ -46,6 +186,213 @@ 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, + onPaymentHistoryClick: () -> Unit, + onUpiTransactionHistoryClick: () -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + onPaymentHistoryClick = onPaymentHistoryClick, + onUpiTransactionHistoryClick = onUpiTransactionHistoryClick, + ) + } +} + +fun NavGraphBuilder.payeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPin: (PayeeDetailsState) -> Unit, +) { + composableWithSlideTransitions( + route = PAYEE_DETAILS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYEE_DETAILS_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + PayeeDetailsScreen( + onBackClick = onBackClick, + onNavigateToPaymentProcessing = onNavigateToUpiPin, + ) + } +} + +fun NavGraphBuilder.upiPinScreen( + onBackClick: () -> Unit, + onNavigateToPaymentProcessing: (String, String, Boolean) -> Unit, +) { + composableWithSlideTransitions( + route = UPI_PIN_BASE_ROUTE, + arguments = listOf( + navArgument(UPI_PIN_PAYEE_NAME_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(UPI_PIN_AMOUNT_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(UPI_PIN_REF_ID_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(UPI_PIN_IS_UPI_ARG) { + type = NavType.BoolType + nullable = false + }, + navArgument(UPI_PIN_BANK_NAME_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(UPI_PIN_ACCOUNT_NO_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + UpiPinScreen( + onBackClick = onBackClick, + onUpiPinEntered = { payeeName, amount, pin, isUpiCode -> + onNavigateToPaymentProcessing(payeeName, amount, isUpiCode) + }, + ) + } +} + +fun NavGraphBuilder.paymentProcessingScreen( + onPaymentComplete: (String, String, String, String) -> Unit, + onPaymentFailed: (String) -> Unit, +) { + composableWithSlideTransitions( + route = PAYMENT_PROCESSING_BASE_ROUTE, + arguments = listOf( + navArgument(PAYMENT_PROCESSING_PAYEE_NAME_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(PAYMENT_PROCESSING_AMOUNT_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(PAYMENT_PROCESSING_IS_UPI_ARG) { + type = NavType.BoolType + nullable = false + }, + ), + ) { + PaymentProcessingScreen( + onPaymentComplete = onPaymentComplete, + onPaymentFailed = onPaymentFailed, + ) + } +} + +fun NavGraphBuilder.paymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, +) { + composableWithSlideTransitions( + route = PAYMENT_SUCCESS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYMENT_SUCCESS_PAYEE_NAME_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(PAYMENT_SUCCESS_AMOUNT_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(PAYMENT_SUCCESS_UPI_NAME_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(PAYMENT_SUCCESS_TRANSACTION_TIMESTAMP_ARG) { + type = NavType.StringType + nullable = true + }, + ), + ) { + PaymentSuccessScreen( + onShareScreenshot = onShareScreenshot, + onDone = onDone, + onNavigateToSendMoneyOptions = onNavigateToSendMoneyOptions, + ) + } +} + +fun NavGraphBuilder.paymentChatHistoryScreen( + onBackClick: () -> Unit, + onPaymentClick: () -> Unit, + onTransactionClick: (String) -> Unit, +) { + composableWithSlideTransitions( + route = PAYMENT_CHAT_HISTORY_ROUTE, + ) { + PaymentChatHistoryScreen( + onBackClick = onBackClick, + onPaymentClick = onPaymentClick, + onTransactionClick = onTransactionClick, + ) + } +} + +fun NavGraphBuilder.upiTransactionHistoryScreen( + onBackClick: () -> Unit, +) { + composableWithSlideTransitions( + route = UPI_TRANSACTION_HISTORY_ROUTE, + ) { + UpiTransactionHistoryScreen( + onBackClick = onBackClick, + ) + } +} + +fun NavGraphBuilder.paymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, +) { + composableWithSlideTransitions( + route = PAYMENT_DETAILS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYMENT_DETAILS_TRANSACTION_ID_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { backStackEntry -> + val transactionId = backStackEntry.savedStateHandle.get(PAYMENT_DETAILS_TRANSACTION_ID_ARG) ?: "" + PaymentDetailsScreen( + onBackClick = onBackClick, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + onShareScreenshot = onShareScreenshot, + transactionId = transactionId, ) } } @@ -63,3 +410,41 @@ fun NavController.navigateToSendMoneyScreen( navigate(route, options) } + +/** + * URL encodes a string to handle special characters in navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + */ +private fun String.urlEncode(): String { + return this.replace("%", "%25") + .replace(" ", "%20") + .replace("&", "%26") + .replace("=", "%3D") + .replace("?", "%3F") + .replace("@", "%40") + .replace("+", "%2B") + .replace("/", "%2F") + .replace(":", "%3A") + .replace("#", "%23") + .replace("\"", "%22") + .replace("'", "%27") + .replace(",", "%2C") + .replace("$", "%24") + .replace(";", "%3B") + .replace("[", "%5B") + .replace("]", "%5D") + .replace("{", "%7B") + .replace("}", "%7D") +} diff --git a/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.desktop.kt b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.desktop.kt new file mode 100644 index 000000000..3300c11d2 --- /dev/null +++ b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.desktop.kt @@ -0,0 +1,40 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * Desktop-specific implementation of PaymentDetailsScreen. + * + * This composable delegates the UI rendering to the common implementation. + * Screenshot functionality is not implemented for desktop. + */ +@Composable +actual fun PaymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier, + viewModel: PaymentDetailsViewModel, + transactionId: String, +) { + PaymentDetailsScreenDefault( + onBackClick = onBackClick, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + onShareScreenshot = onShareScreenshot, + modifier = modifier, + viewModel = viewModel, + transactionId = transactionId, + ) +} diff --git a/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.desktop.kt b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.desktop.kt new file mode 100644 index 000000000..4a70a2c2d --- /dev/null +++ b/feature/send-money/src/desktopMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.desktop.kt @@ -0,0 +1,35 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * Desktop stub implementation of PaymentSuccessScreen. + * + * Screenshot functionality is not implemented for Desktop yet. + */ +@Composable +actual fun PaymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier, + viewModel: PaymentSuccessViewModel, +) { + PaymentSuccessScreenDefault( + onShareScreenshot = onShareScreenshot, + onDone = onDone, + onNavigateToSendMoneyOptions = onNavigateToSendMoneyOptions, + modifier = modifier, + viewModel = viewModel, + ) +} diff --git a/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.js.kt b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.js.kt new file mode 100644 index 000000000..092170147 --- /dev/null +++ b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.js.kt @@ -0,0 +1,40 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * JS-specific implementation of PaymentDetailsScreen. + * + * This composable delegates the UI rendering to the common implementation. + * Screenshot functionality is not implemented for JS. + */ +@Composable +actual fun PaymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier, + viewModel: PaymentDetailsViewModel, + transactionId: String, +) { + PaymentDetailsScreenDefault( + onBackClick = onBackClick, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + onShareScreenshot = onShareScreenshot, + modifier = modifier, + viewModel = viewModel, + transactionId = transactionId, + ) +} diff --git a/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.js.kt b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.js.kt new file mode 100644 index 000000000..7c9282140 --- /dev/null +++ b/feature/send-money/src/jsMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.js.kt @@ -0,0 +1,35 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * JS stub implementation of PaymentSuccessScreen. + * + * Screenshot functionality is not available in JS environments. + */ +@Composable +actual fun PaymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier, + viewModel: PaymentSuccessViewModel, +) { + PaymentSuccessScreenDefault( + onShareScreenshot = onShareScreenshot, + onDone = onDone, + onNavigateToSendMoneyOptions = onNavigateToSendMoneyOptions, + modifier = modifier, + viewModel = viewModel, + ) +} diff --git a/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.native.kt b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.native.kt new file mode 100644 index 000000000..9789d7dd1 --- /dev/null +++ b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.native.kt @@ -0,0 +1,40 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * iOS-specific implementation of PaymentDetailsScreen. + * + * This composable delegates the UI rendering to the common implementation. + * Screenshot functionality is not implemented for iOS. + */ +@Composable +actual fun PaymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier, + viewModel: PaymentDetailsViewModel, + transactionId: String, +) { + PaymentDetailsScreenDefault( + onBackClick = onBackClick, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + onShareScreenshot = onShareScreenshot, + modifier = modifier, + viewModel = viewModel, + transactionId = transactionId, + ) +} diff --git a/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.native.kt b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.native.kt new file mode 100644 index 000000000..5185a538e --- /dev/null +++ b/feature/send-money/src/nativeMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.native.kt @@ -0,0 +1,35 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * iOS stub implementation of PaymentSuccessScreen. + * + * Screenshot functionality is not implemented for iOS yet. + */ +@Composable +actual fun PaymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier, + viewModel: PaymentSuccessViewModel, +) { + PaymentSuccessScreenDefault( + onShareScreenshot = onShareScreenshot, + onDone = onDone, + onNavigateToSendMoneyOptions = onNavigateToSendMoneyOptions, + modifier = modifier, + viewModel = viewModel, + ) +} diff --git a/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.wasmJs.kt b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.wasmJs.kt new file mode 100644 index 000000000..e3b539860 --- /dev/null +++ b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/PaymentDetailsScreen.wasmJs.kt @@ -0,0 +1,40 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * WASM/JS-specific implementation of PaymentDetailsScreen. + * + * This composable delegates the UI rendering to the common implementation. + * Screenshot functionality is not implemented for WASM/JS. + */ +@Composable +actual fun PaymentDetailsScreen( + onBackClick: () -> Unit, + onPayAgainClick: () -> Unit, + onRetryClick: () -> Unit, + onShareScreenshot: () -> Unit, + modifier: Modifier, + viewModel: PaymentDetailsViewModel, + transactionId: String, +) { + PaymentDetailsScreenDefault( + onBackClick = onBackClick, + onPayAgainClick = onPayAgainClick, + onRetryClick = onRetryClick, + onShareScreenshot = onShareScreenshot, + modifier = modifier, + viewModel = viewModel, + transactionId = transactionId, + ) +} diff --git a/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.wasmJs.kt b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.wasmJs.kt new file mode 100644 index 000000000..1993d081d --- /dev/null +++ b/feature/send-money/src/wasmJsMain/kotlin/org/mifospay/feature/send/money/PaymentSuccessScreen.wasmJs.kt @@ -0,0 +1,35 @@ +/* + * 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 androidx.compose.ui.Modifier + +/** + * WASM/JS stub implementation of PaymentSuccessScreen. + * + * Screenshot functionality is not available in WASM/JS environments. + */ +@Composable +actual fun PaymentSuccessScreen( + onShareScreenshot: () -> Unit, + onDone: () -> Unit, + onNavigateToSendMoneyOptions: () -> Unit, + modifier: Modifier, + viewModel: PaymentSuccessViewModel, +) { + PaymentSuccessScreenDefault( + onShareScreenshot = onShareScreenshot, + onDone = onDone, + onNavigateToSendMoneyOptions = onNavigateToSendMoneyOptions, + modifier = modifier, + viewModel = viewModel, + ) +}