From 4f8eb56db3d538fe87bc90fd9454dc3d2afc3a5c Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Tue, 5 Aug 2025 23:14:08 +0530 Subject: [PATCH 01/10] feat(feature:send-money): add UPI QR code processor --- .../data/util/StandardUpiQrCodeProcessor.kt | 101 ++++++++++++++++++ .../core/model/utils/StandardUpiQrData.kt | 34 ++++++ .../mifospay/feature/qr/ScanQrViewModel.kt | 11 +- .../composeResources/values/strings.xml | 2 + .../feature/send/money/SendMoneyScreen.kt | 7 +- .../feature/send/money/SendMoneyViewModel.kt | 16 ++- .../send/money/navigation/SendNavigation.kt | 7 +- 7 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt create mode 100644 core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt new file mode 100644 index 000000000..4cd41951b --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import org.mifospay.core.model.utils.PaymentQrData +import org.mifospay.core.model.utils.StandardUpiQrData + +/** + * Standard UPI QR Code Processor + * Handles parsing of standard UPI QR codes according to UPI specification + */ +object StandardUpiQrCodeProcessor { + + /** + * Checks if the given string is a valid UPI QR code + * @param qrData The QR code data string + * @return true if it's a valid UPI QR code, false otherwise + */ + fun isValidUpiQrCode(qrData: String): Boolean { + return qrData.startsWith("upi://") || qrData.startsWith("UPI://") + } + + /** + * Parses a standard UPI QR code string + * @param qrData The QR code data string + * @return StandardUpiQrData object with parsed information + * @throws IllegalArgumentException if the QR code is invalid + */ + fun parseUpiQrCode(qrData: String): StandardUpiQrData { + if (!isValidUpiQrCode(qrData)) { + throw IllegalArgumentException("Invalid UPI QR code format") + } + + val paramsString = qrData.substringAfter("upi://").substringAfter("UPI://") + + val parts = paramsString.split("?", limit = 2) + val params = if (parts.size > 1) parseParams(parts[1]) else emptyMap() + + val payeeVpa = params["pa"] ?: throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + val payeeName = params["pn"] ?: "Unknown" + + val vpaParts = payeeVpa.split("@", limit = 2) + val actualVpa = if (vpaParts.size == 2) payeeVpa else payeeVpa + + return StandardUpiQrData( + payeeName = payeeName, + payeeVpa = actualVpa, + amount = params["am"] ?: "", + currency = params["cu"] ?: StandardUpiQrData.DEFAULT_CURRENCY, + transactionNote = params["tn"] ?: "", + merchantCode = params["mc"] ?: "", + transactionReference = params["tr"] ?: "", + url = params["url"] ?: "", + mode = params["mode"] ?: "02", + ) + } + + /** + * Parses URL parameters into a map + * @param paramsString The parameters string + * @return Map of parameter keys and values + */ + private fun parseParams(paramsString: String): Map { + return paramsString + .split("&") + .associate { param -> + val keyValue = param.split("=", limit = 2) + if (keyValue.size == 2) { + keyValue[0] to keyValue[1] + } else { + param to "" + } + } + } + + /** + * Converts StandardUpiQrData to PaymentQrData for compatibility with existing code + * @param standardData Standard UPI QR data + * @return PaymentQrData object + * Note: clientId and accountId not available in standard UPI + */ + fun toPaymentQrData(standardData: StandardUpiQrData): PaymentQrData { + return PaymentQrData( + clientId = 0, + clientName = standardData.payeeName, + accountNo = standardData.payeeVpa, + amount = standardData.amount, + accountId = 0, + currency = standardData.currency, + officeId = 1, + accountTypeId = 2, + ) + } +} diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt new file mode 100644 index 000000000..861d4c6bb --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.utils + +import kotlinx.serialization.Serializable + +/** + * Data class representing standard UPI QR code data + * Based on UPI QR code specification + */ +@Serializable +data class StandardUpiQrData( + val payeeName: String, + val payeeVpa: String, + val amount: String = "", + val currency: String = "INR", + val transactionNote: String = "", + val merchantCode: String = "", + val transactionReference: String = "", + val url: String = "", + // 02 for QR code + val mode: String = "02", +) { + companion object { + const val DEFAULT_CURRENCY = "INR" + } +} diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index cea7f82b7..286d076b7 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor class ScanQrViewModel : ViewModel() { @@ -22,7 +23,15 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - UpiQrCodeProcessor.decodeUpiString(data) + try { + UpiQrCodeProcessor.decodeUpiString(data) + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(data) + } else { + throw e + } + } _eventFlow.update { ScanQrEvent.OnNavigateToSendScreen(data) diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64d..c824b9123 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,6 @@ Account cannot be empty Requesting payment QR but found - %1$s Failed to request payment QR: required data is missing + UPI QR code parsed successfully + External UPI Payment \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 66d7bebd2..87a3552e7 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -109,6 +108,11 @@ fun SendMoneyScreen( } is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() + + is SendMoneyEvent.ShowToast -> { + // TODO: Implement toast message display + // For now, we'll just ignore it + } } } @@ -130,7 +134,6 @@ fun SendMoneyScreen( ) } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun SendMoneyScreen( state: SendMoneyState, diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index 3ee69208a..3e07b4766 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -33,11 +33,13 @@ import mobile_wallet.feature.send_money.generated.resources.feature_send_money_e import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_invalid_amount import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_but_found import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_data_missing +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_upi_qr_parsed_successfully import org.jetbrains.compose.resources.StringResource import org.mifospay.core.common.DataState import org.mifospay.core.common.getSerialized import org.mifospay.core.common.setSerialized import org.mifospay.core.data.repository.AccountRepository +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor import org.mifospay.core.model.search.AccountResult import org.mifospay.core.model.utils.PaymentQrData @@ -176,7 +178,16 @@ class SendMoneyViewModel( private fun handleRequestData(action: HandleRequestData) { viewModelScope.launch { try { - val requestData = UpiQrCodeProcessor.decodeUpiString(action.requestData) + val requestData = try { + UpiQrCodeProcessor.decodeUpiString(action.requestData) + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(action.requestData)) { + val standardData = StandardUpiQrCodeProcessor.parseUpiQrCode(action.requestData) + StandardUpiQrCodeProcessor.toPaymentQrData(standardData) + } else { + throw e + } + } mutableStateFlow.update { state -> state.copy( @@ -185,6 +196,8 @@ class SendMoneyViewModel( selectedAccount = requestData.toAccount(), ) } + + sendEvent(SendMoneyEvent.ShowToast(Res.string.feature_send_money_upi_qr_parsed_successfully)) } catch (e: Exception) { val errorState = if (action.requestData.isNotEmpty()) { Error.GenericResourceMessage( @@ -260,6 +273,7 @@ sealed interface SendMoneyEvent { data object OnNavigateBack : SendMoneyEvent data class NavigateToTransferScreen(val data: String) : SendMoneyEvent data object NavigateToScanQrScreen : SendMoneyEvent + data class ShowToast(val message: StringResource) : SendMoneyEvent } sealed interface SendMoneyAction { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 04af30a0a..c1ddf3e8e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -14,6 +14,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.NavType import androidx.navigation.navArgument +import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions import org.mifospay.feature.send.money.SendMoneyScreen @@ -54,9 +55,9 @@ fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) { val route = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG=$requestData" - val options = navOptions ?: NavOptions.Builder() - .setPopUpTo(SEND_MONEY_ROUTE, inclusive = true) - .build() + val options = navOptions ?: navOptions { + popUpTo(SEND_MONEY_ROUTE) { inclusive = true } + } navigate(route, options) } From 001e2604377d1fb392e323eeb1822643a425670b Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Wed, 13 Aug 2025 22:59:19 +0530 Subject: [PATCH 02/10] feat(feature:send-money): add screen for payment options --- cmp-android/prodRelease-badging.txt | 2 +- .../shared/navigation/MifosNavHost.kt | 31 +- .../make/transfer/MakeTransferViewModel.kt | 6 +- .../composeResources/values/strings.xml | 8 + .../send/money/SendMoneyOptionsScreen.kt | 454 ++++++++++++++++++ .../send/money/SendMoneyOptionsViewModel.kt | 67 +++ .../feature/send/money/SendMoneyViewModel.kt | 15 +- .../feature/send/money/di/SendMoneyModule.kt | 2 + .../send/money/navigation/SendNavigation.kt | 29 ++ 9 files changed, 599 insertions(+), 15 deletions(-) create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index b57f9c278..35f0ec441 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.2-beta.0.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index d6a94277b..35db493a4 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -72,7 +72,10 @@ import org.mifospay.feature.savedcards.details.cardDetailRoute import org.mifospay.feature.savedcards.details.navigateToCardDetails import org.mifospay.feature.send.money.SendMoneyScreen import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE +import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE +import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen import org.mifospay.feature.standing.instruction.StandingInstructionsScreen @@ -160,7 +163,7 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -279,6 +282,32 @@ internal fun MifosNavHost( navigateBack = navController::navigateUp, ) + sendMoneyOptionsScreen( + onBackClick = navController::popBackStack, + onScanQrClick = { + // This is now handled by the ViewModel using ML Kit scanner + }, + onPayAnyoneClick = { + // TODO: Navigate to Pay Anyone screen + }, + onBankTransferClick = { + // TODO: Navigate to Bank Transfer screen + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onQrCodeScanned = { qrData -> + navController.navigateToSendMoneyScreen( + requestData = qrData, + navOptions = navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { + inclusive = true + } + }, + ) + }, + ) + sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt index 7df3fb44d..fe7c7a09d 100644 --- a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.make_transfer.generated.resources.Res import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_amount import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_description @@ -207,7 +208,7 @@ internal data class MakeTransferState( val amount: String = toClientData.amount, val description: String = "", val selectedAccount: Account? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && amount.toDoubleOrNull() != null @@ -232,12 +233,9 @@ internal data class MakeTransferState( transferDate = DateHelper.formattedShortDate, ) - @Serializable sealed interface DialogState { - @Serializable data object Loading : DialogState - @Serializable sealed interface Error : DialogState { data class StringMessage(val message: String) : Error data class ResourceMessage(val message: StringResource) : Error diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index c824b9123..ccdfcdb74 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -40,4 +40,12 @@ Failed to request payment QR: required data is missing UPI QR code parsed successfully External UPI Payment + Choose how you want to send money + Scan any QR code + Pay anyone + Bank Transfer + Fineract Payments + People + Merchants + More \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt new file mode 100644 index 000000000..95a675fd6 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,454 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_choose_method +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_fineract_payments +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_merchants +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_more +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_pay_anyone +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_people +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_scan_qr_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_send +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun SendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SendMoneyOptionsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + SendMoneyOptionsEvent.NavigateBack -> onBackClick.invoke() + SendMoneyOptionsEvent.NavigateToPayAnyone -> onPayAnyoneClick.invoke() + SendMoneyOptionsEvent.NavigateToBankTransfer -> onBankTransferClick.invoke() + SendMoneyOptionsEvent.NavigateToFineractPayments -> onFineractPaymentsClick.invoke() + is SendMoneyOptionsEvent.QrCodeScanned -> onQrCodeScanned.invoke(event.data) + } + } + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_send), + backPress = { + viewModel.trySendAction(SendMoneyOptionsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyBanner() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + SendMoneyOptionsRow( + onScanQrClick = { + viewModel.trySendAction(SendMoneyOptionsAction.ScanQrClicked) + }, + onPayAnyoneClick = { + viewModel.trySendAction(SendMoneyOptionsAction.PayAnyoneClicked) + }, + onBankTransferClick = { + viewModel.trySendAction(SendMoneyOptionsAction.BankTransferClicked) + }, + onFineractPaymentsClick = { + viewModel.trySendAction(SendMoneyOptionsAction.FineractPaymentsClicked) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PeopleSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + MerchantsSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + } + } + } +} + +@Composable +private fun SendMoneyBanner( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.xl), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_send_money_choose_method), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } +} + +@Composable +private fun SendMoneyOptionsRow( + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.Scan, + label = stringResource(Res.string.feature_send_money_scan_qr_code), + onClick = onScanQrClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Person, + label = stringResource(Res.string.feature_send_money_pay_anyone), + onClick = onPayAnyoneClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Bank, + label = stringResource(Res.string.feature_send_money_bank_transfer), + onClick = onBankTransferClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Payment, + label = stringResource(Res.string.feature_send_money_fineract_payments), + onClick = onFineractPaymentsClick, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun SendMoneyOptionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { onClick() }, + color = KptTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(56.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(KptTheme.spacing.sm), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + text = label, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} + +@Composable +private fun PeopleSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. People functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_people), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "John Doe", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Jane Smith", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Mike Johnson", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Sarah Wilson", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "David Brown", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Lisa Davis", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Tom Miller", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun MerchantsSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. Merchants functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_merchants), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Coffee Shop", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Grocery Store", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Restaurant", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Gas Station", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Pharmacy", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bookstore", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bakery", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun PersonItem( + name: String, + isMoreButton: Boolean = false, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { /* TODO: Handle click */ } + .clip(RoundedCornerShape(KptTheme.spacing.sm)), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = if (isMoreButton) { + KptTheme.colorScheme.secondaryContainer + } else { + KptTheme.colorScheme.primaryContainer + }, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isMoreButton) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = name, + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.onSecondaryContainer, + ) + } else { + Text( + text = name.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Text( + text = name, + style = KptTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt new file mode 100644 index 000000000..46979fc4a --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.mifospay.core.ui.utils.BaseViewModel + +class SendMoneyOptionsViewModel( + private val scanner: QrScanner, +) : BaseViewModel( + initialState = SendMoneyOptionsState(), +) { + + override fun handleAction(action: SendMoneyOptionsAction) { + when (action) { + is SendMoneyOptionsAction.NavigateBack -> { + sendEvent(SendMoneyOptionsEvent.NavigateBack) + } + is SendMoneyOptionsAction.ScanQrClicked -> { + // Use ML Kit QR scanner directly + scanner.startScanning().onEach { data -> + data?.let { result -> + sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + } + }.launchIn(viewModelScope) + } + is SendMoneyOptionsAction.PayAnyoneClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToPayAnyone) + } + is SendMoneyOptionsAction.BankTransferClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToBankTransfer) + } + is SendMoneyOptionsAction.FineractPaymentsClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToFineractPayments) + } + } + } +} + +data class SendMoneyOptionsState( + val isLoading: Boolean = false, +) + +sealed interface SendMoneyOptionsEvent { + data object NavigateBack : SendMoneyOptionsEvent + data object NavigateToPayAnyone : SendMoneyOptionsEvent + data object NavigateToBankTransfer : SendMoneyOptionsEvent + data object NavigateToFineractPayments : SendMoneyOptionsEvent + data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent +} + +sealed interface SendMoneyOptionsAction { + data object NavigateBack : SendMoneyOptionsAction + data object ScanQrClicked : SendMoneyOptionsAction + data object PayAnyoneClicked : SendMoneyOptionsAction + data object BankTransferClicked : SendMoneyOptionsAction + data object FineractPaymentsClicked : SendMoneyOptionsAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index 3e07b4766..ceacb65df 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -25,8 +25,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.send_money.generated.resources.Res import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_account_cannot_be_empty import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_amount_cannot_be_empty @@ -223,7 +223,7 @@ data class SendMoneyState( val amount: String = "", val accountNumber: String = "", val selectedAccount: AccountResult? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && @@ -242,19 +242,16 @@ data class SendMoneyState( amount = amount, ) - @Serializable sealed interface DialogState { - @Serializable + data object Loading : DialogState - @Serializable sealed interface Error : DialogState { - @Serializable - data class ResourceMessage(@Contextual val message: StringResource) : Error - @Serializable + data class ResourceMessage(val message: StringResource) : Error + data class GenericResourceMessage( - @Contextual val message: StringResource, + val message: StringResource, val args: List, ) : Error } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index 16dd21815..421f314c3 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -12,9 +12,11 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import org.mifospay.feature.send.money.ScannerModule +import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + viewModelOf(::SendMoneyOptionsViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index c1ddf3e8e..d3a5314af 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -16,6 +16,7 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen const val SEND_MONEY_ROUTE = "send_money_route" @@ -23,10 +24,16 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" +const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" + fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_ROUTE, navOptions) +fun NavController.navigateToSendMoneyOptionsScreen( + navOptions: NavOptions? = null, +) = navigate(SEND_MONEY_OPTIONS_ROUTE, navOptions) + fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, @@ -50,6 +57,28 @@ fun NavGraphBuilder.sendMoneyScreen( } } +fun NavGraphBuilder.sendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onQrCodeScanned = onQrCodeScanned, + ) + } +} + fun NavController.navigateToSendMoneyScreen( requestData: String, navOptions: NavOptions? = null, From c7e26d04dbffb75ab255ca5f5dc0cd06446dcabb Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Thu, 14 Aug 2025 08:13:39 +0530 Subject: [PATCH 03/10] feat(feature:send-money): fit names in one line --- .../org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt index 95a675fd6..c16e42aae 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import mobile_wallet.feature.send_money.generated.resources.Res @@ -447,7 +448,8 @@ private fun PersonItem( fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, color = KptTheme.colorScheme.onSurface, - maxLines = 2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From e20f6f8e2cde930004224f18f242298ada698ee6 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Thu, 14 Aug 2025 20:21:53 +0530 Subject: [PATCH 04/10] feat(feature:send-money): implement upi and non upi scanner navigation --- cmp-android/prodRelease-badging.txt | 2 +- .../shared/navigation/MifosNavHost.kt | 27 ++ .../core/designsystem/icon/MifosIcons.kt | 2 + .../mifospay/feature/qr/ScanQrCodeScreen.kt | 5 + .../mifospay/feature/qr/ScanQrViewModel.kt | 13 +- .../feature/qr/navigation/ReadQrNavigation.kt | 2 + .../feature/send/money/QrScanner.android.kt | 7 +- .../feature/send/money/PayeeDetailsScreen.kt | 272 ++++++++++++++++++ .../send/money/PayeeDetailsViewModel.kt | 95 ++++++ .../send/money/SendMoneyOptionsScreen.kt | 24 +- .../send/money/SendMoneyOptionsViewModel.kt | 14 +- .../feature/send/money/SendMoneyScreen.kt | 5 + .../feature/send/money/SendMoneyViewModel.kt | 9 +- .../feature/send/money/di/SendMoneyModule.kt | 2 + .../send/money/navigation/SendNavigation.kt | 43 +++ 15 files changed, 507 insertions(+), 15 deletions(-) create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt create mode 100644 feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 35f0ec441..0080f4765 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index 35db493a4..4d63084ca 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -73,8 +73,10 @@ import org.mifospay.feature.savedcards.details.navigateToCardDetails import org.mifospay.feature.send.money.SendMoneyScreen import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE +import org.mifospay.feature.send.money.navigation.navigateToPayeeDetailsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.payeeDetailsScreen import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen @@ -100,6 +102,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -306,14 +309,28 @@ internal fun MifosNavHost( }, ) }, + onNavigateToPayeeDetails = { qrCodeData -> + navController.navigateToPayeeDetailsScreen(qrCodeData) + }, ) sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, + navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen, navigateToScanQrScreen = navController::navigateToScanQr, ) + payeeDetailsScreen( + onBackClick = navController::popBackStack, + onNavigateToUpiPayment = { state -> + // TODO: Handle UPI payment navigation + }, + onNavigateToFineractPayment = { state -> + // TODO: Handle Fineract payment navigation + }, + ) + transferScreen( navigateBack = navController::popBackStack, onTransferSuccess = { @@ -351,6 +368,16 @@ internal fun MifosNavHost( }, ) }, + navigateToPayeeDetailsScreen = { + navController.navigateToPayeeDetailsScreen( + qrCodeData = it, + navOptions = navOptions { + popUpTo(SCAN_QR_ROUTE) { + inclusive = true + } + }, + ) + }, ) merchantTransferScreen( diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index 5bca46905..72d73fe1f 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CurrencyRupee import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit @@ -129,4 +130,5 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + val Currency = Icons.Filled.CurrencyRupee } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt index 66513f281..0cbfbadb1 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt @@ -30,6 +30,7 @@ import org.mifospay.core.designsystem.component.MifosScaffold internal fun ScanQrCodeScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, modifier: Modifier = Modifier, viewModel: ScanQrViewModel = koinViewModel(), ) { @@ -44,6 +45,10 @@ internal fun ScanQrCodeScreen( navigateToSendScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToSendScreen).data) } + is ScanQrEvent.OnNavigateToPayeeDetails -> { + navigateToPayeeDetailsScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToPayeeDetails).data) + } + is ScanQrEvent.ShowToast -> { scope.launch { snackbarHostState.showSnackbar((eventFlow as ScanQrEvent.ShowToast).message) diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index 286d076b7..33b8d7e20 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -23,18 +23,24 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - try { + val isUpiQr = try { UpiQrCodeProcessor.decodeUpiString(data) + true } catch (e: Exception) { if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { StandardUpiQrCodeProcessor.parseUpiQrCode(data) + true } else { - throw e + false } } _eventFlow.update { - ScanQrEvent.OnNavigateToSendScreen(data) + if (isUpiQr) { + ScanQrEvent.OnNavigateToPayeeDetails(data) + } else { + ScanQrEvent.OnNavigateToSendScreen(data) + } } true @@ -49,5 +55,6 @@ class ScanQrViewModel : ViewModel() { sealed interface ScanQrEvent { data class OnNavigateToSendScreen(val data: String) : ScanQrEvent + data class OnNavigateToPayeeDetails(val data: String) : ScanQrEvent data class ShowToast(val message: String) : ScanQrEvent } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt index 89bbe6b19..c8a3e25dd 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt @@ -23,11 +23,13 @@ fun NavController.navigateToScanQr(navOptions: NavOptions? = null) = fun NavGraphBuilder.scanQrScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, ) { composableWithSlideTransitions(route = SCAN_QR_ROUTE) { ScanQrCodeScreen( navigateBack = navigateBack, navigateToSendScreen = navigateToSendScreen, + navigateToPayeeDetailsScreen = navigateToPayeeDetailsScreen, ) } } diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt index 135592b86..5d2e1ff55 100644 --- a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt @@ -39,11 +39,12 @@ class QrScannerImp( override fun startScanning(): Flow { return callbackFlow { scanner.startScan() - .addOnSuccessListener { + .addOnSuccessListener { barcode -> launch { - send(it.rawValue) + val rawValue = barcode.rawValue + send(rawValue) } - }.addOnFailureListener { + }.addOnFailureListener { exception -> launch { send(null) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt new file mode 100644 index 000000000..8c504a981 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun PayeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, + modifier: Modifier = Modifier, + viewModel: PayeeDetailsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + PayeeDetailsEvent.NavigateBack -> onBackClick.invoke() + is PayeeDetailsEvent.NavigateToUpiPayment -> onNavigateToUpiPayment.invoke(event.state) + is PayeeDetailsEvent.NavigateToFineractPayment -> onNavigateToFineractPayment.invoke(event.state) + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = "Payee Details", + backPress = { + viewModel.trySendAction(PayeeDetailsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PayeeProfileSection(state) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + PaymentDetailsSection( + state = state, + onAmountChange = { amount -> + viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) + }, + onNoteChange = { note -> + viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + + ProceedButton( + state = state, + onProceedClick = { + viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + } + } + } +} + +@Composable +private fun PayeeProfileSection( + state: PayeeDetailsState, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(80.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + if (state.payeeName.isNotEmpty()) { + Text( + text = state.payeeName, + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + val contactInfo = if (state.isUpiCode) { + state.upiId + } else { + state.phoneNumber + } + + if (contactInfo.isNotEmpty()) { + Text( + text = contactInfo, + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PaymentDetailsSection( + state: PayeeDetailsState, + onAmountChange: (String) -> Unit, + onNoteChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Text( + text = "Payment Details", + style = KptTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + OutlinedTextField( + value = state.amount, + onValueChange = onAmountChange, + label = { Text("Amount") }, + enabled = state.isAmountEditable, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon( + imageVector = MifosIcons.Currency, + contentDescription = "Amount", + tint = KptTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + + OutlinedTextField( + value = state.note, + onValueChange = { newValue -> + if (newValue.length <= 50) { + onNoteChange(newValue) + } + }, + placeholder = { Text("Add note") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2, + singleLine = false, + ) + } + } +} + +@Composable +private fun ProceedButton( + state: PayeeDetailsState, + onProceedClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isAmountValid = state.amount.isNotEmpty() && state.amount.toDoubleOrNull() != null + val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() + + Button( + onClick = onProceedClick, + enabled = isAmountValid && isContactValid, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + contentColor = KptTheme.colorScheme.onPrimary, + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) { + Text( + text = if (state.isUpiCode) "Proceed to UPI Payment" else "Proceed to Payment", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = KptTheme.spacing.sm), + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt new file mode 100644 index 000000000..8607c65d1 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BaseViewModel + +class PayeeDetailsViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = PayeeDetailsState(), +) { + + init { + val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" + + if (safeQrCodeDataString.isNotEmpty()) { + // Restore & characters that were replaced for safe navigation + val qrCodeDataString = safeQrCodeDataString.replace("___AMP___", "&") + val qrCodeData = if (StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(qrCodeDataString) + } else { + // For non-UPI QR codes, create a basic StandardUpiQrData + StandardUpiQrCodeProcessor.parseUpiQrCode("upi://pay?pa=$qrCodeDataString&pn=Unknown") + } + + mutableStateFlow.update { + it.copy( + payeeName = qrCodeData.payeeName, + upiId = qrCodeData.payeeVpa, + phoneNumber = "", + amount = qrCodeData.amount, + note = qrCodeData.transactionNote, + isAmountEditable = qrCodeData.amount.isEmpty(), + isUpiCode = true, + ) + } + } + } + + override fun handleAction(action: PayeeDetailsAction) { + when (action) { + is PayeeDetailsAction.NavigateBack -> { + sendEvent(PayeeDetailsEvent.NavigateBack) + } + is PayeeDetailsAction.UpdateAmount -> { + mutableStateFlow.value = stateFlow.value.copy(amount = action.amount) + } + is PayeeDetailsAction.UpdateNote -> { + mutableStateFlow.value = stateFlow.value.copy(note = action.note) + } + is PayeeDetailsAction.ProceedToPayment -> { + val currentState = stateFlow.value + if (currentState.isUpiCode) { + sendEvent(PayeeDetailsEvent.NavigateToUpiPayment(currentState)) + } else { + sendEvent(PayeeDetailsEvent.NavigateToFineractPayment(currentState)) + } + } + } + } +} + +data class PayeeDetailsState( + val payeeName: String = "", + val upiId: String = "", + val phoneNumber: String = "", + val amount: String = "", + val note: String = "", + val isAmountEditable: Boolean = true, + val isUpiCode: Boolean = false, + val isLoading: Boolean = false, +) + +sealed interface PayeeDetailsEvent { + data object NavigateBack : PayeeDetailsEvent + data class NavigateToUpiPayment(val state: PayeeDetailsState) : PayeeDetailsEvent + data class NavigateToFineractPayment(val state: PayeeDetailsState) : PayeeDetailsEvent +} + +sealed interface PayeeDetailsAction { + data object NavigateBack : PayeeDetailsAction + data class UpdateAmount(val amount: String) : PayeeDetailsAction + data class UpdateNote(val note: String) : PayeeDetailsAction + data object ProceedToPayment : PayeeDetailsAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt index c16e42aae..aaa28a8c1 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -67,6 +67,7 @@ fun SendMoneyOptionsScreen( onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, modifier: Modifier = Modifier, viewModel: SendMoneyOptionsViewModel = koinViewModel(), ) { @@ -74,11 +75,24 @@ fun SendMoneyOptionsScreen( EventsEffect(viewModel) { event -> when (event) { - SendMoneyOptionsEvent.NavigateBack -> onBackClick.invoke() - SendMoneyOptionsEvent.NavigateToPayAnyone -> onPayAnyoneClick.invoke() - SendMoneyOptionsEvent.NavigateToBankTransfer -> onBankTransferClick.invoke() - SendMoneyOptionsEvent.NavigateToFineractPayments -> onFineractPaymentsClick.invoke() - is SendMoneyOptionsEvent.QrCodeScanned -> onQrCodeScanned.invoke(event.data) + SendMoneyOptionsEvent.NavigateBack -> { + onBackClick.invoke() + } + SendMoneyOptionsEvent.NavigateToPayAnyone -> { + onPayAnyoneClick.invoke() + } + SendMoneyOptionsEvent.NavigateToBankTransfer -> { + onBankTransferClick.invoke() + } + SendMoneyOptionsEvent.NavigateToFineractPayments -> { + onFineractPaymentsClick.invoke() + } + is SendMoneyOptionsEvent.QrCodeScanned -> { + onQrCodeScanned.invoke(event.data) + } + is SendMoneyOptionsEvent.NavigateToPayeeDetails -> { + onNavigateToPayeeDetails.invoke(event.qrCodeData) + } } } MifosGradientBackground { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt index 46979fc4a..0e82e041e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -12,6 +12,8 @@ package org.mifospay.feature.send.money import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BackgroundEvent import org.mifospay.core.ui.utils.BaseViewModel class SendMoneyOptionsViewModel( @@ -29,7 +31,14 @@ class SendMoneyOptionsViewModel( // Use ML Kit QR scanner directly scanner.startScanning().onEach { data -> data?.let { result -> - sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + // Check if it's a UPI QR code or regular QR code + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + // Navigate to payee details screen for UPI QR codes + sendEvent(SendMoneyOptionsEvent.NavigateToPayeeDetails(result)) + } else { + // For non-UPI QR codes, navigate to Fineract payment + sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + } } }.launchIn(viewModelScope) } @@ -55,7 +64,8 @@ sealed interface SendMoneyOptionsEvent { data object NavigateToPayAnyone : SendMoneyOptionsEvent data object NavigateToBankTransfer : SendMoneyOptionsEvent data object NavigateToFineractPayments : SendMoneyOptionsEvent - data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent + data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent, BackgroundEvent + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyOptionsEvent, BackgroundEvent } sealed interface SendMoneyOptionsAction { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 87a3552e7..cd49c6f32 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt @@ -91,6 +91,7 @@ import template.core.base.designsystem.theme.KptTheme fun SendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetails: (String) -> Unit, navigateToScanQrScreen: () -> Unit, showTopBar: Boolean = true, modifier: Modifier = Modifier, @@ -107,6 +108,10 @@ fun SendMoneyScreen( navigateToTransferScreen(event.data) } + is SendMoneyEvent.NavigateToPayeeDetails -> { + navigateToPayeeDetails(event.qrCodeData) + } + is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() is SendMoneyEvent.ShowToast -> { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index ceacb65df..6a9e9bb73 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -44,6 +44,7 @@ import org.mifospay.core.data.util.UpiQrCodeProcessor import org.mifospay.core.model.search.AccountResult import org.mifospay.core.model.utils.PaymentQrData import org.mifospay.core.model.utils.toAccount +import org.mifospay.core.ui.utils.BackgroundEvent import org.mifospay.core.ui.utils.BaseViewModel import org.mifospay.feature.send.money.SendMoneyAction.HandleRequestData import org.mifospay.feature.send.money.SendMoneyState.DialogState.Error @@ -122,7 +123,11 @@ class SendMoneyViewModel( SendMoneyAction.OnClickScan -> { scanner.startScanning().onEach { data -> data?.let { result -> - sendAction(HandleRequestData(result)) + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + sendEvent(SendMoneyEvent.NavigateToPayeeDetails(result)) + } else { + sendAction(HandleRequestData(result)) + } } }.launchIn(viewModelScope) // Using Play Service Code Scanner until Qr Scan module is stable @@ -270,6 +275,8 @@ sealed interface SendMoneyEvent { data object OnNavigateBack : SendMoneyEvent data class NavigateToTransferScreen(val data: String) : SendMoneyEvent data object NavigateToScanQrScreen : SendMoneyEvent + + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyEvent, BackgroundEvent data class ShowToast(val message: StringResource) : SendMoneyEvent } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index 421f314c3..8af69abde 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -11,6 +11,7 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.send.money.PayeeDetailsViewModel import org.mifospay.feature.send.money.ScannerModule import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel @@ -19,4 +20,5 @@ val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) viewModelOf(::SendMoneyOptionsViewModel) + viewModelOf(::PayeeDetailsViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index d3a5314af..3d4322496 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -16,6 +16,8 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.PayeeDetailsScreen +import org.mifospay.feature.send.money.PayeeDetailsState import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen @@ -25,6 +27,10 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" +const val PAYEE_DETAILS_ROUTE = "payee_details_route" +const val PAYEE_DETAILS_ARG = "qrCodeData" + +const val PAYEE_DETAILS_BASE_ROUTE = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG={$PAYEE_DETAILS_ARG}" fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, @@ -34,9 +40,21 @@ fun NavController.navigateToSendMoneyOptionsScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_OPTIONS_ROUTE, navOptions) +fun NavController.navigateToPayeeDetailsScreen( + qrCodeData: String, + navOptions: NavOptions? = null, +) { + val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$qrCodeData" + val options = navOptions ?: navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { inclusive = false } + } + navigate(route, options) +} + fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, navigateToScanQrScreen: () -> Unit, ) { composableWithSlideTransitions( @@ -53,6 +71,7 @@ fun NavGraphBuilder.sendMoneyScreen( onBackClick = onBackClick, navigateToTransferScreen = navigateToTransferScreen, navigateToScanQrScreen = navigateToScanQrScreen, + navigateToPayeeDetails = navigateToPayeeDetailsScreen, ) } } @@ -64,6 +83,7 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, ) { composableWithSlideTransitions( route = SEND_MONEY_OPTIONS_ROUTE, @@ -75,6 +95,29 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( onBankTransferClick = onBankTransferClick, onFineractPaymentsClick = onFineractPaymentsClick, onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + ) + } +} + +fun NavGraphBuilder.payeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, +) { + composableWithSlideTransitions( + route = PAYEE_DETAILS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYEE_DETAILS_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + PayeeDetailsScreen( + onBackClick = onBackClick, + onNavigateToUpiPayment = onNavigateToUpiPayment, + onNavigateToFineractPayment = onNavigateToFineractPayment, ) } } From 3c31541c14195fd4778f4f25c60d20379c5aced8 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Fri, 15 Aug 2025 11:28:04 +0530 Subject: [PATCH 05/10] fix(feature:send-money): fetch UPI data in payee details screen --- cmp-android/prodRelease-badging.txt | 2 +- .../data/util/StandardUpiQrCodeProcessor.kt | 5 +- .../feature/send/money/PayeeDetailsScreen.kt | 278 +++++++++++++++--- .../send/money/PayeeDetailsViewModel.kt | 96 +++++- .../send/money/navigation/SendNavigation.kt | 43 ++- 5 files changed, 368 insertions(+), 56 deletions(-) diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 0080f4765..2769ef610 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt index 4cd41951b..545f7b574 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -39,11 +39,12 @@ object StandardUpiQrCodeProcessor { } val paramsString = qrData.substringAfter("upi://").substringAfter("UPI://") - val parts = paramsString.split("?", limit = 2) val params = if (parts.size > 1) parseParams(parts[1]) else emptyMap() - val payeeVpa = params["pa"] ?: throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + val payeeVpa = params["pa"] ?: run { + throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + } val payeeName = params["pn"] ?: "Unknown" val vpaParts = payeeVpa.split("@", limit = 2) diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt index 8c504a981..3cd48fcb3 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -9,18 +9,28 @@ */ package org.mifospay.feature.send.money +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -29,16 +39,23 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import org.mifospay.core.designsystem.component.MifosGradientBackground @@ -90,7 +107,7 @@ fun PayeeDetailsScreen( PayeeProfileSection(state) - Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) PaymentDetailsSection( state = state, @@ -146,17 +163,48 @@ private fun PayeeProfileSection( ), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = MifosIcons.Person, - contentDescription = "Payee Profile", - modifier = Modifier.size(40.dp), - tint = KptTheme.colorScheme.onPrimaryContainer, - ) + if (state.payeeName.isNotEmpty() && state.payeeName != "UNKNOWN") { + val firstLetter = state.payeeName + .replace("%20", " ") + .trim() + .firstOrNull() + ?.uppercase() + + if (firstLetter != null) { + Text( + text = firstLetter, + style = KptTheme.typography.headlineLarge.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ), + color = KptTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + ) + } else { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } else { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } } - if (state.payeeName.isNotEmpty()) { + if (state.payeeName.isNotEmpty() && state.payeeName != "UNKNOWN") { + val decodedName = state.payeeName + .replace("%20", " ") + .trim() + Text( - text = state.payeeName, + text = "Paying ${decodedName.uppercase()}", style = KptTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, color = KptTheme.colorScheme.onSurface, @@ -165,7 +213,7 @@ private fun PayeeProfileSection( } val contactInfo = if (state.isUpiCode) { - state.upiId + "UPI ID: ${state.upiId}" } else { state.phoneNumber } @@ -190,54 +238,191 @@ private fun PaymentDetailsSection( onNoteChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - Card( + Column( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = KptTheme.colorScheme.surface, - ), - shape = RoundedCornerShape(KptTheme.spacing.md), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), ) { - Column( + ExpandableAmountInput( + value = state.formattedAmount, + onValueChange = onAmountChange, + enabled = state.isAmountEditable, + modifier = Modifier.wrapContentWidth(), + ) + + AnimatedVisibility( + visible = state.showMaxAmountMessage, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)), + ) { + val vibrationOffset by animateFloatAsState( + targetValue = if (state.showMaxAmountMessage) 1f else 0f, + animationSpec = repeatable( + iterations = 3, + animation = tween(100, delayMillis = 0), + ), + label = "vibration", + ) + + Text( + text = "Amount cannot be more than ₹ 5,00,000", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.error, + modifier = Modifier + .padding(top = KptTheme.spacing.xs) + .graphicsLayer { + translationX = if (state.showMaxAmountMessage) { + (vibrationOffset * 10f * (if (vibrationOffset % 2 == 0f) 1f else -1f)) + } else { + 0f + } + }, + ) + } + + ExpandableNoteInput( + value = state.note, + onValueChange = onNoteChange, + modifier = Modifier.wrapContentWidth(), + ) + } +} + +@Composable +private fun ExpandableAmountInput( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val displayValue = value.ifEmpty { "0" } + + Column(modifier = modifier) { + Row( modifier = Modifier - .fillMaxWidth() - .padding(KptTheme.spacing.lg), - verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + .wrapContentWidth() + .clip(RoundedCornerShape(KptTheme.spacing.sm)) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) + .padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { Text( - text = "Payment Details", - style = KptTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = KptTheme.colorScheme.onSurface, + text = "₹", + style = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + ), ) - OutlinedTextField( - value = state.amount, - onValueChange = onAmountChange, - label = { Text("Amount") }, - enabled = state.isAmountEditable, + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + BasicTextField( + value = displayValue, + onValueChange = { newValue -> + val cleanValue = newValue.replace(",", "").replace(".", "") + if (cleanValue.isEmpty() || cleanValue.toLongOrNull() != null) { + val amount = cleanValue.toLongOrNull() ?: 0L + if (amount <= 500000) { + onValueChange(cleanValue) + } + } + }, + enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - leadingIcon = { - Icon( - imageVector = MifosIcons.Currency, - contentDescription = "Amount", - tint = KptTheme.colorScheme.onSurfaceVariant, + textStyle = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width( + when { + displayValue.length <= 1 -> 24.dp + displayValue.length <= 3 -> displayValue.length * 16.dp + displayValue.length <= 6 -> displayValue.length * 14.dp + else -> displayValue.length * 12.dp + }, ) - }, + .focusRequester(focusRequester), + singleLine = true, ) + } + } +} + +@Composable +private fun ExpandableNoteInput( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } - OutlinedTextField( - value = state.note, + Column(modifier = modifier) { + Row( + modifier = Modifier + .wrapContentWidth() + .clip(RoundedCornerShape(KptTheme.spacing.sm)) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) + .padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = value, onValueChange = { newValue -> if (newValue.length <= 50) { - onNoteChange(newValue) + onValueChange(newValue) + } + }, + enabled = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + textStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = if (value.isEmpty()) KptTheme.colorScheme.onSurfaceVariant else KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width( + when { + value.length <= 7 -> 7 * 12.dp + value.length <= 28 -> (value.length + 1) * 12.dp + else -> 28 * 12.dp + }, + ) + .focusRequester(focusRequester), + singleLine = value.length <= 28, + maxLines = if (value.length > 28) 2 else 1, + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = "Add note", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + ) } + innerTextField() }, - placeholder = { Text("Add note") }, - modifier = Modifier.fillMaxWidth(), - maxLines = 2, - singleLine = false, ) } } @@ -249,7 +434,10 @@ private fun ProceedButton( onProceedClick: () -> Unit, modifier: Modifier = Modifier, ) { - val isAmountValid = state.amount.isNotEmpty() && state.amount.toDoubleOrNull() != null + val isAmountValid = state.amount.isNotEmpty() && + state.amount.toLongOrNull() != null && + state.amount.toLong() > 0 && + !state.isAmountExceedingMax val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() Button( diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt index 8607c65d1..e4d23ed9c 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -24,12 +24,13 @@ class PayeeDetailsViewModel( val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" if (safeQrCodeDataString.isNotEmpty()) { - // Restore & characters that were replaced for safe navigation - val qrCodeDataString = safeQrCodeDataString.replace("___AMP___", "&") - val qrCodeData = if (StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString)) { + // URL decode the QR code data to restore special characters + val qrCodeDataString = safeQrCodeDataString.urlDecode() + val isUpiCode = StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString) + + val qrCodeData = if (isUpiCode) { StandardUpiQrCodeProcessor.parseUpiQrCode(qrCodeDataString) } else { - // For non-UPI QR codes, create a basic StandardUpiQrData StandardUpiQrCodeProcessor.parseUpiQrCode("upi://pay?pa=$qrCodeDataString&pn=Unknown") } @@ -53,7 +54,18 @@ class PayeeDetailsViewModel( sendEvent(PayeeDetailsEvent.NavigateBack) } is PayeeDetailsAction.UpdateAmount -> { - mutableStateFlow.value = stateFlow.value.copy(amount = action.amount) + val cleanAmount = action.amount.replace(",", "") + val isValidAmount = cleanAmount.isEmpty() || cleanAmount.toDoubleOrNull() != null + + if (isValidAmount) { + val amountValue = cleanAmount.toDoubleOrNull() ?: 0.0 + val showMessage = amountValue > 500000 + + mutableStateFlow.value = stateFlow.value.copy( + amount = cleanAmount, + showMaxAmountMessage = showMessage, + ) + } } is PayeeDetailsAction.UpdateNote -> { mutableStateFlow.value = stateFlow.value.copy(note = action.note) @@ -79,7 +91,39 @@ data class PayeeDetailsState( val isAmountEditable: Boolean = true, val isUpiCode: Boolean = false, val isLoading: Boolean = false, -) + val showMaxAmountMessage: Boolean = false, +) { + val formattedAmount: String + get() = if (amount.isEmpty()) "0" else formatAmountWithCommas(amount) + + val isAmountExceedingMax: Boolean + get() = amount.toDoubleOrNull()?.let { it > 500000 } ?: false + + private fun formatAmountWithCommas(amountStr: String): String { + val cleanAmount = amountStr.replace(",", "") + return try { + val amount = cleanAmount.toDouble() + if (amount == 0.0) return "0" + + val parts = amount.toString().split(".") + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + val formattedInteger = integerPart.reversed() + .chunked(3) + .joinToString(",") + .reversed() + + if (decimalPart.isNotEmpty()) { + "$formattedInteger.$decimalPart" + } else { + formattedInteger + } + } catch (e: NumberFormatException) { + amountStr + } + } +} sealed interface PayeeDetailsEvent { data object NavigateBack : PayeeDetailsEvent @@ -93,3 +137,43 @@ sealed interface PayeeDetailsAction { data class UpdateNote(val note: String) : PayeeDetailsAction data object ProceedToPayment : PayeeDetailsAction } + +/** + * URL decodes a string to restore special characters from navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + * + * Note: %25 (percent) must be decoded last to avoid double decoding. + */ +private fun String.urlDecode(): String { + return this.replace("%20", " ") + .replace("%26", "&") + .replace("%3D", "=") + .replace("%3F", "?") + .replace("%40", "@") + .replace("%2B", "+") + .replace("%2F", "/") + .replace("%3A", ":") + .replace("%23", "#") + .replace("%22", "\"") + .replace("%27", "'") + .replace("%2C", ",") + .replace("%24", "$") + .replace("%3B", ";") + .replace("%5B", "[") + .replace("%5D", "]") + .replace("%7B", "{") + .replace("%7D", "}") + .replace("%25", "%") +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 3d4322496..00962212e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -44,13 +44,14 @@ fun NavController.navigateToPayeeDetailsScreen( qrCodeData: String, navOptions: NavOptions? = null, ) { - val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$qrCodeData" + // URL encode the QR code data to handle special characters like &, =, etc. + val encodedQrCodeData = qrCodeData.urlEncode() + val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$encodedQrCodeData" val options = navOptions ?: navOptions { popUpTo(SEND_MONEY_OPTIONS_ROUTE) { inclusive = false } } navigate(route, options) } - fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, @@ -133,3 +134,41 @@ fun NavController.navigateToSendMoneyScreen( navigate(route, options) } + +/** + * URL encodes a string to handle special characters in navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + */ +private fun String.urlEncode(): String { + return this.replace("%", "%25") + .replace(" ", "%20") + .replace("&", "%26") + .replace("=", "%3D") + .replace("?", "%3F") + .replace("@", "%40") + .replace("+", "%2B") + .replace("/", "%2F") + .replace(":", "%3A") + .replace("#", "%23") + .replace("\"", "%22") + .replace("'", "%27") + .replace(",", "%2C") + .replace("$", "%24") + .replace(";", "%3B") + .replace("[", "%5B") + .replace("]", "%5D") + .replace("{", "%7B") + .replace("}", "%7D") +} From fa8e9afdd4ced9eee1b94b6b470b1623b9b6678e Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Fri, 15 Aug 2025 13:26:14 +0530 Subject: [PATCH 06/10] refactor(send-money): enhance payment details UI/UX --- cmp-android/prodRelease-badging.txt | 2 +- .../core/designsystem/icon/MifosIcons.kt | 6 +- .../feature/send/money/PayeeDetailsScreen.kt | 178 ++++++++++++------ .../send/money/PayeeDetailsViewModel.kt | 31 ++- 4 files changed, 154 insertions(+), 63 deletions(-) diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 2769ef610..a7582b12e 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.3-beta.0.5' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index 72d73fe1f..005e46a97 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -11,6 +11,7 @@ package org.mifospay.core.designsystem.icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.AttachMoney @@ -130,5 +131,8 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked - val Currency = Icons.Filled.CurrencyRupee + + val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward + + val CurrencyRupee = Icons.Filled.CurrencyRupee } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt index 3cd48fcb3..59a4fdc13 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -19,8 +19,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -41,13 +43,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -95,40 +101,52 @@ fun PayeeDetailsScreen( ) }, ) { paddingValues -> - Column( + Box( modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .padding(horizontal = KptTheme.spacing.lg) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + .fillMaxSize() + .padding(paddingValues), ) { - Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) - PayeeProfileSection(state) + PayeeProfileSection(state) - Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xs)) - PaymentDetailsSection( - state = state, - onAmountChange = { amount -> - viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) - }, - onNoteChange = { note -> - viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) - }, - ) + PaymentDetailsSection( + state = state, + onAmountChange = { amount -> + viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) + }, + onNoteChange = { note -> + viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) + }, + onNoteFieldFocused = { + viewModel.trySendAction(PayeeDetailsAction.NoteFieldFocused) + }, + ) - Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + } ProceedButton( state = state, onProceedClick = { viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = KptTheme.spacing.lg, + bottom = KptTheme.spacing.lg, + ), ) - - Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) } } } @@ -236,6 +254,7 @@ private fun PaymentDetailsSection( state: PayeeDetailsState, onAmountChange: (String) -> Unit, onNoteChange: (String) -> Unit, + onNoteFieldFocused: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -283,11 +302,13 @@ private fun PaymentDetailsSection( ExpandableNoteInput( value = state.note, onValueChange = onNoteChange, + onFieldFocused = onNoteFieldFocused, modifier = Modifier.wrapContentWidth(), ) } } +// TODO improve amount validation and UI/UX @Composable private fun ExpandableAmountInput( value: String, @@ -298,6 +319,31 @@ private fun ExpandableAmountInput( val focusRequester = remember { FocusRequester() } val displayValue = value.ifEmpty { "0" } + /** + * Calculate width based on the display value + * When showing "0" (single digit), use minimal width + * When user enters decimal or additional digits, expand dynamically + * Maximum amount is ₹5,00,000 (6 digits + decimal + up to 2 decimal places = max 9 characters) + */ + val textFieldWidth = when { + displayValue == "0" -> 24.dp + displayValue.length == 2 -> 32.dp + displayValue.length == 3 -> 48.dp + displayValue.length == 4 -> 64.dp + displayValue.length == 5 -> 80.dp + displayValue.length == 6 -> 96.dp + displayValue.length == 7 -> 112.dp + displayValue.length == 8 -> 128.dp + displayValue.length == 9 -> 144.dp + else -> 144.dp // Maximum width for ₹5,00,000.00 + } + + LaunchedEffect(enabled) { + if (enabled) { + focusRequester.requestFocus() + } + } + Column(modifier = modifier) { Row( modifier = Modifier @@ -314,13 +360,10 @@ private fun ExpandableAmountInput( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - Text( - text = "₹", - style = TextStyle( - fontSize = 24.sp, - fontWeight = FontWeight.Medium, - color = KptTheme.colorScheme.onSurface, - ), + Icon( + imageVector = MifosIcons.CurrencyRupee, + contentDescription = "Rupee Icon", + tint = KptTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) @@ -328,16 +371,19 @@ private fun ExpandableAmountInput( BasicTextField( value = displayValue, onValueChange = { newValue -> - val cleanValue = newValue.replace(",", "").replace(".", "") - if (cleanValue.isEmpty() || cleanValue.toLongOrNull() != null) { - val amount = cleanValue.toLongOrNull() ?: 0L - if (amount <= 500000) { - onValueChange(cleanValue) - } + val cleanValue = newValue.replace(",", "") + if (cleanValue.isEmpty() || cleanValue.toDoubleOrNull() != null) { + val amount = cleanValue.toDoubleOrNull() ?: 0.0 + + /** + * Allow the input to be processed by ViewModel for error handling + * The ViewModel will show error message briefly for invalid amounts + */ + onValueChange(cleanValue) } }, enabled = enabled, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), textStyle = TextStyle( fontSize = 24.sp, fontWeight = FontWeight.Medium, @@ -345,14 +391,7 @@ private fun ExpandableAmountInput( textAlign = TextAlign.Center, ), modifier = Modifier - .width( - when { - displayValue.length <= 1 -> 24.dp - displayValue.length <= 3 -> displayValue.length * 16.dp - displayValue.length <= 6 -> displayValue.length * 14.dp - else -> displayValue.length * 12.dp - }, - ) + .width(textFieldWidth) .focusRequester(focusRequester), singleLine = true, ) @@ -360,13 +399,16 @@ private fun ExpandableAmountInput( } } +// TODO improve add note UI/UX @Composable private fun ExpandableNoteInput( value: String, onValueChange: (String) -> Unit, + onFieldFocused: () -> Unit, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } Column(modifier = modifier) { Row( @@ -406,7 +448,13 @@ private fun ExpandableNoteInput( else -> 28 * 12.dp }, ) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused && !isFocused) { + isFocused = true + onFieldFocused() + } + }, singleLine = value.length <= 28, maxLines = if (value.length > 28) 2 else 1, decorationBox = { innerTextField -> @@ -428,33 +476,51 @@ private fun ExpandableNoteInput( } } +// TODO improve UI/UX of proceed button @Composable private fun ProceedButton( state: PayeeDetailsState, onProceedClick: () -> Unit, modifier: Modifier = Modifier, ) { - val isAmountValid = state.amount.isNotEmpty() && - state.amount.toLongOrNull() != null && - state.amount.toLong() > 0 && - !state.isAmountExceedingMax + val isAmountValid = if (state.isUpiCode) { + state.amount.isNotEmpty() && + state.amount.toDoubleOrNull() != null && + state.amount.toDouble() >= 0 && + !state.isAmountExceedingMax + } else { + state.amount.isNotEmpty() && + state.amount.toDoubleOrNull() != null && + state.amount.toDouble() > 0 && + !state.isAmountExceedingMax + } val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() + val isAmountPrefilled = !state.isAmountEditable + val showCheckMark = isAmountValid && isContactValid && (isAmountPrefilled || state.hasNoteFieldBeenFocused) Button( onClick = onProceedClick, enabled = isAmountValid && isContactValid, - modifier = modifier.fillMaxWidth(), + modifier = modifier.size(56.dp), colors = ButtonDefaults.buttonColors( - containerColor = KptTheme.colorScheme.primary, - contentColor = KptTheme.colorScheme.onPrimary, + containerColor = if (isAmountValid && isContactValid) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.surfaceVariant + }, + contentColor = if (isAmountValid && isContactValid) { + KptTheme.colorScheme.onPrimary + } else { + KptTheme.colorScheme.onSurfaceVariant + }, ), shape = RoundedCornerShape(KptTheme.spacing.sm), + contentPadding = PaddingValues(0.dp), ) { - Text( - text = if (state.isUpiCode) "Proceed to UPI Payment" else "Proceed to Payment", - style = KptTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(vertical = KptTheme.spacing.sm), + Icon( + imageVector = if (showCheckMark) MifosIcons.Check else MifosIcons.ArrowForward, + contentDescription = if (showCheckMark) "Proceed" else "Next", + modifier = Modifier.size(32.dp), ) } } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt index e4d23ed9c..87baf0e9e 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -10,7 +10,10 @@ package org.mifospay.feature.send.money import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.ui.utils.BaseViewModel @@ -24,7 +27,6 @@ class PayeeDetailsViewModel( val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" if (safeQrCodeDataString.isNotEmpty()) { - // URL decode the QR code data to restore special characters val qrCodeDataString = safeQrCodeDataString.urlDecode() val isUpiCode = StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString) @@ -65,11 +67,23 @@ class PayeeDetailsViewModel( amount = cleanAmount, showMaxAmountMessage = showMessage, ) + + if (showMessage) { + viewModelScope.launch { + delay(2000) + mutableStateFlow.value = stateFlow.value.copy( + showMaxAmountMessage = false, + ) + } + } } } is PayeeDetailsAction.UpdateNote -> { mutableStateFlow.value = stateFlow.value.copy(note = action.note) } + is PayeeDetailsAction.NoteFieldFocused -> { + mutableStateFlow.value = stateFlow.value.copy(hasNoteFieldBeenFocused = true) + } is PayeeDetailsAction.ProceedToPayment -> { val currentState = stateFlow.value if (currentState.isUpiCode) { @@ -92,6 +106,7 @@ data class PayeeDetailsState( val isUpiCode: Boolean = false, val isLoading: Boolean = false, val showMaxAmountMessage: Boolean = false, + val hasNoteFieldBeenFocused: Boolean = false, ) { val formattedAmount: String get() = if (amount.isEmpty()) "0" else formatAmountWithCommas(amount) @@ -103,7 +118,7 @@ data class PayeeDetailsState( val cleanAmount = amountStr.replace(",", "") return try { val amount = cleanAmount.toDouble() - if (amount == 0.0) return "0" + if (amount == 0.0) return if (isUpiCode) "0.00" else "0" val parts = amount.toString().split(".") val integerPart = parts[0] @@ -114,10 +129,15 @@ data class PayeeDetailsState( .joinToString(",") .reversed() - if (decimalPart.isNotEmpty()) { - "$formattedInteger.$decimalPart" + if (isUpiCode) { + val paddedDecimalPart = decimalPart.padEnd(2, '0').take(2) + "$formattedInteger.$paddedDecimalPart" } else { - formattedInteger + if (decimalPart.isNotEmpty()) { + "$formattedInteger.$decimalPart" + } else { + formattedInteger + } } } catch (e: NumberFormatException) { amountStr @@ -135,6 +155,7 @@ sealed interface PayeeDetailsAction { data object NavigateBack : PayeeDetailsAction data class UpdateAmount(val amount: String) : PayeeDetailsAction data class UpdateNote(val note: String) : PayeeDetailsAction + data object NoteFieldFocused : PayeeDetailsAction data object ProceedToPayment : PayeeDetailsAction } From 069353fb633d33f7c3096f16549f8f96a45d37ce Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Wed, 20 Aug 2025 23:49:39 +0530 Subject: [PATCH 07/10] feat(feature:autopay): add module structure and navigation integration --- .../prodReleaseRuntimeClasspath.tree.txt | 32 ++++ .../prodReleaseRuntimeClasspath.txt | 1 + cmp-android/prodRelease-badging.txt | 2 +- cmp-shared/build.gradle.kts | 1 + .../org/mifospay/shared/di/KoinModules.kt | 2 + .../shared/navigation/MifosNavHost.kt | 31 ++++ .../core/designsystem/icon/MifosIcons.kt | 2 + feature/autopay/README.md | 51 ++++++ feature/autopay/build.gradle.kts | 28 ++++ .../feature/autopay/AutoPayHistoryScreen.kt | 54 ++++++ .../feature/autopay/AutoPayNavigation.kt | 90 ++++++++++ .../autopay/AutoPayPreferencesScreen.kt | 54 ++++++ .../feature/autopay/AutoPayRulesScreen.kt | 54 ++++++ .../mifospay/feature/autopay/AutoPayScreen.kt | 156 ++++++++++++++++++ .../feature/autopay/AutoPaySetupScreen.kt | 54 ++++++ .../feature/autopay/AutoPayViewModel.kt | 88 ++++++++++ .../feature/autopay/di/AutoPayModule.kt | 18 ++ .../composeResources/values/strings.xml | 1 + .../org/mifospay/feature/home/HomeScreen.kt | 83 ++++++---- .../mifospay/feature/home/HomeViewModel.kt | 6 + .../feature/home/navigation/HomeNavigation.kt | 2 + .../feature/payments/PaymentsScreen.kt | 1 + settings.gradle.kts | 1 + 23 files changed, 783 insertions(+), 29 deletions(-) create mode 100644 feature/autopay/README.md create mode 100644 feature/autopay/build.gradle.kts create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index f666a3614..d46291228 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -3012,6 +3012,38 @@ | | +--- org.jetbrains.compose.material3:material3:1.8.2 (*) | | +--- org.jetbrains.compose.components:components-resources:1.8.2 (*) | | \--- org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 (*) +| +--- project :feature:autopay +| | +--- androidx.lifecycle:lifecycle-runtime-compose:2.9.2 (*) +| | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2 (*) +| | +--- androidx.tracing:tracing-ktx:1.3.0 (*) +| | +--- io.insert-koin:koin-bom:4.1.0 (*) +| | +--- io.insert-koin:koin-android:4.1.0 (*) +| | +--- io.insert-koin:koin-androidx-compose:4.1.0 (*) +| | +--- io.insert-koin:koin-androidx-navigation:4.1.0 (*) +| | +--- io.insert-koin:koin-core-viewmodel:4.1.0 (*) +| | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.1.21 (*) +| | +--- io.insert-koin:koin-core:4.1.0 (*) +| | +--- io.insert-koin:koin-annotations:2.1.0 (*) +| | +--- project :core:ui (*) +| | +--- project :core:designsystem (*) +| | +--- project :core:data (*) +| | +--- io.insert-koin:koin-compose:4.1.0 (*) +| | +--- io.insert-koin:koin-compose-viewmodel:4.1.0 (*) +| | +--- org.jetbrains.compose.runtime:runtime:1.8.2 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.1 (*) +| | +--- org.jetbrains.androidx.savedstate:savedstate:1.3.1 (*) +| | +--- org.jetbrains.androidx.core:core-bundle:1.0.1 (*) +| | +--- org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 -> 1.8.0 (*) +| | +--- org.jetbrains.compose.ui:ui:1.8.2 (*) +| | +--- org.jetbrains.compose.foundation:foundation:1.8.2 (*) +| | +--- org.jetbrains.compose.material3:material3:1.8.2 (*) +| | +--- org.jetbrains.compose.components:components-resources:1.8.2 (*) +| | \--- org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 (*) | \--- org.jetbrains.kotlin:kotlin-parcelize-runtime:2.1.20 (*) +--- project :core:data (*) +--- project :core:ui (*) diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt index 89aaf0d21..7d2e8a654 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt @@ -12,6 +12,7 @@ :core:ui :feature:accounts :feature:auth +:feature:autopay :feature:editpassword :feature:faq :feature:finance diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 91ced69b5..522e01de8 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.2-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/build.gradle.kts b/cmp-shared/build.gradle.kts index 0b8ef8a66..0c2ef4174 100644 --- a/cmp-shared/build.gradle.kts +++ b/cmp-shared/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { implementation(projects.feature.qr) implementation(projects.feature.merchants) implementation(projects.feature.upiSetup) + implementation(projects.feature.autopay) } desktopMain.dependencies { diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt index b87dc3783..9d8cb8366 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt @@ -24,6 +24,7 @@ import org.mifospay.core.network.di.LocalModule import org.mifospay.core.network.di.NetworkModule import org.mifospay.feature.accounts.di.AccountsModule import org.mifospay.feature.auth.di.AuthModule +import org.mifospay.feature.autopay.di.AutoPayModule import org.mifospay.feature.editpassword.di.EditPasswordModule import org.mifospay.feature.faq.di.FaqModule import org.mifospay.feature.history.di.HistoryModule @@ -88,6 +89,7 @@ object KoinModules { QrModule, MerchantsModule, UpiSetupModule, + AutoPayModule, ) } private val LibraryModule = module { diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index 4d63084ca..3ed7df5b0 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -22,6 +22,13 @@ import org.mifospay.feature.accounts.savingsaccount.addEditSavingAccountScreen import org.mifospay.feature.accounts.savingsaccount.details.navigateToSavingAccountDetails import org.mifospay.feature.accounts.savingsaccount.details.savingAccountDetailRoute import org.mifospay.feature.accounts.savingsaccount.navigateToSavingAccountAddEdit +import org.mifospay.feature.autopay.AutoPayScreen +import org.mifospay.feature.autopay.autoPayGraph +import org.mifospay.feature.autopay.navigateToAutoPay +import org.mifospay.feature.autopay.navigateToAutoPayHistory +import org.mifospay.feature.autopay.navigateToAutoPayPreferences +import org.mifospay.feature.autopay.navigateToAutoPayRules +import org.mifospay.feature.autopay.navigateToAutoPaySetup import org.mifospay.feature.editpassword.navigation.editPasswordScreen import org.mifospay.feature.editpassword.navigation.navigateToEditPassword import org.mifospay.feature.faq.navigation.faqScreen @@ -127,6 +134,22 @@ internal fun MifosNavHost( navigateToInvoiceDetailScreen = navController::navigateToInvoiceDetail, ) }, + TabContent(PaymentsScreenContents.AUTOPAY.name) { + AutoPayScreen( + onNavigateToSetup = { + navController.navigateToAutoPaySetup() + }, + onNavigateToRules = { + navController.navigateToAutoPayRules() + }, + onNavigateToPreferences = { + navController.navigateToAutoPayPreferences() + }, + onNavigateToHistory = { + navController.navigateToAutoPayHistory() + }, + ) + }, ) val tabContents = listOf( @@ -167,6 +190,9 @@ internal fun MifosNavHost( navController.navigateToShowQrScreen() }, onPay = navController::navigateToSendMoneyOptionsScreen, + onAutoPay = { + navController.navigateToAutoPay() + }, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -388,5 +414,10 @@ internal fun MifosNavHost( setupUpiPinScreen( navigateBack = navController::navigateUp, ) + + autoPayGraph( + navController = navController, + onNavigateBack = navController::navigateUp, + ) } } 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 005e46a97..6c9ac6f67 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Person @@ -135,4 +136,5 @@ object MifosIcons { val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward val CurrencyRupee = Icons.Filled.CurrencyRupee + val History = Icons.Filled.History } diff --git a/feature/autopay/README.md b/feature/autopay/README.md new file mode 100644 index 000000000..f2fce2b91 --- /dev/null +++ b/feature/autopay/README.md @@ -0,0 +1,51 @@ +# AutoPay Feature + +## Overview +The AutoPay feature module provides functionality for setting up and managing automatic payment schedules. This module allows users to configure recurring payments, set up payment rules, and manage their automatic payment preferences. + +## Features +- Set up recurring payment schedules +- Configure payment rules and conditions +- Manage automatic payment preferences +- View payment history and status +- Enable/disable automatic payments + +## Screenshots +### Android +*Screenshots will be added as the feature is developed* + +### Desktop +*Screenshots will be added as the feature is developed* + +### Web +*Screenshots will be added as the feature is developed* + +## Module Structure +``` +feature/autopay/ +├── src/ +│ ├── commonMain/ +│ │ ├── kotlin/org/mifospay/feature/autopay/ +│ │ │ ├── di/ +│ │ │ │ └── AutoPayModule.kt +│ │ │ ├── AutoPayScreen.kt +│ │ │ ├── AutoPayNavigation.kt +│ │ │ └── AutoPayViewModel.kt +│ │ └── composeResources/ +│ └── androidMain/ +│ └── kotlin/org/mifospay/feature/autopay/ +├── build.gradle.kts +└── README.md +``` + +## Dependencies +- Compose UI components +- Material3 design system +- Koin dependency injection +- Core domain modules (as needed) + +## Usage +This module is designed to be integrated into the main application through dependency injection. The AutoPayModule provides the necessary dependencies for the AutoPay feature. + +## Development Status +🚧 **In Development** - Basic module structure created diff --git a/feature/autopay/build.gradle.kts b/feature/autopay/build.gradle.kts new file mode 100644 index 000000000..6b0eaec56 --- /dev/null +++ b/feature/autopay/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.cmp.feature.convention) +} + +android { + namespace = "org.mifospay.feature.autopay" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt new file mode 100644 index 000000000..85af684c2 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPayHistoryScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay History", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "View your automatic payment history", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt new file mode 100644 index 000000000..92a53011d --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.navOptions + +object AutoPayNavigation { + const val AUTO_PAY_ROUTE = "autopay" + const val AUTO_PAY_SETUP_ROUTE = "autopay/setup" + const val AUTO_PAY_RULES_ROUTE = "autopay/rules" + const val AUTO_PAY_PREFERENCES_ROUTE = "autopay/preferences" + const val AUTO_PAY_HISTORY_ROUTE = "autopay/history" +} + +fun NavController.navigateToAutoPay(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPaySetup(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_SETUP_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayRules(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_RULES_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayPreferences(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayHistory(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE, navOptions) +} + +fun NavGraphBuilder.autoPayGraph( + navController: NavController, + onNavigateBack: () -> Unit = { navController.navigateUp() }, +) { + composable(AutoPayNavigation.AUTO_PAY_ROUTE) { + AutoPayScreen( + onNavigateToSetup = { + navController.navigateToAutoPaySetup() + }, + onNavigateToRules = { + navController.navigateToAutoPayRules() + }, + onNavigateToPreferences = { + navController.navigateToAutoPayPreferences() + }, + onNavigateToHistory = { + navController.navigateToAutoPayHistory() + }, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_SETUP_ROUTE) { + AutoPaySetupScreen( + onNavigateBack = onNavigateBack, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_RULES_ROUTE) { + AutoPayRulesScreen( + onNavigateBack = onNavigateBack, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE) { + AutoPayPreferencesScreen( + onNavigateBack = onNavigateBack, + ) + } + + composable(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE) { + AutoPayHistoryScreen( + onNavigateBack = onNavigateBack, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt new file mode 100644 index 000000000..2b2330fb4 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPayPreferencesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay Preferences", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Customize your AutoPay settings", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt new file mode 100644 index 000000000..3106a196b --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPayRulesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay Rules", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Manage your payment conditions and schedules", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt new file mode 100644 index 000000000..bdb35b727 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.icon.MifosIcons + +@Composable +fun AutoPayScreen( + onNavigateToSetup: () -> Unit, + onNavigateToRules: () -> Unit, + onNavigateToPreferences: () -> Unit, + onNavigateToHistory: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AutoPayViewModel = koinViewModel(), +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "AutoPay", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Manage your automatic payments", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + AutoPayOptionCard( + title = "Setup AutoPay", + description = "Configure automatic payment rules", + icon = MifosIcons.Settings, + onClick = onNavigateToSetup, + ) + + AutoPayOptionCard( + title = "Payment Rules", + description = "Manage payment conditions and schedules", + icon = MifosIcons.Payment, + onClick = onNavigateToRules, + ) + + AutoPayOptionCard( + title = "Preferences", + description = "Customize AutoPay settings", + icon = MifosIcons.Profile, + onClick = onNavigateToPreferences, + ) + + AutoPayOptionCard( + title = "History", + description = "View AutoPay transaction history", + icon = MifosIcons.History, + onClick = onNavigateToHistory, + ) + } + } +} + +@Composable +private fun AutoPayOptionCard( + title: String, + description: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt new file mode 100644 index 000000000..d7ad18f56 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AutoPaySetupScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "AutoPay Setup", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = "Configure your automatic payment rules", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt new file mode 100644 index 000000000..8f782e2e3 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayViewModel : BaseViewModel( + initialState = AutoPayState(), +) { + + override fun handleAction(action: AutoPayAction) { + when (action) { + is AutoPayAction.SetupRecurringPayment -> { + setupRecurringPayment() + } + is AutoPayAction.ConfigurePaymentRules -> { + configurePaymentRules() + } + is AutoPayAction.ManagePaymentPreferences -> { + managePaymentPreferences() + } + is AutoPayAction.ToggleAutoPay -> { + toggleAutoPay(action.enabled) + } + is AutoPayAction.GetPaymentHistory -> { + getPaymentHistory() + } + } + } + + private fun setupRecurringPayment() { + // TODO: Implement recurring payment setup logic + sendEvent(AutoPayEvent.NavigateToSetup) + } + + private fun configurePaymentRules() { + // TODO: Implement payment rules configuration + sendEvent(AutoPayEvent.NavigateToRules) + } + + private fun managePaymentPreferences() { + // TODO: Implement payment preferences management + sendEvent(AutoPayEvent.NavigateToPreferences) + } + + private fun toggleAutoPay(enabled: Boolean) { + // TODO: Implement auto-pay toggle logic + mutableStateFlow.update { + it.copy(isAutoPayEnabled = enabled) + } + } + + private fun getPaymentHistory() { + // TODO: Implement payment history retrieval + sendEvent(AutoPayEvent.NavigateToHistory) + } +} + +@Serializable +data class AutoPayState( + val isAutoPayEnabled: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, +) + +sealed interface AutoPayEvent { + data object NavigateToSetup : AutoPayEvent + data object NavigateToRules : AutoPayEvent + data object NavigateToPreferences : AutoPayEvent + data object NavigateToHistory : AutoPayEvent +} + +sealed interface AutoPayAction { + data object SetupRecurringPayment : AutoPayAction + data object ConfigurePaymentRules : AutoPayAction + data object ManagePaymentPreferences : AutoPayAction + data object GetPaymentHistory : AutoPayAction + data class ToggleAutoPay(val enabled: Boolean) : AutoPayAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt new file mode 100644 index 000000000..34f148218 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay.di + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module +import org.mifospay.feature.autopay.AutoPayViewModel + +val AutoPayModule = module { + viewModelOf(::AutoPayViewModel) +} diff --git a/feature/home/src/commonMain/composeResources/values/strings.xml b/feature/home/src/commonMain/composeResources/values/strings.xml index 6c0266da6..4c44be41b 100644 --- a/feature/home/src/commonMain/composeResources/values/strings.xml +++ b/feature/home/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,7 @@ Request Money Send Send Money + AutoPay Coin Account type diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt index a2b31672f..eea5654fe 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt @@ -78,6 +78,7 @@ import mobile_wallet.feature.home.generated.resources.arrow_backward import mobile_wallet.feature.home.generated.resources.coin_image import mobile_wallet.feature.home.generated.resources.feature_home_account_type import mobile_wallet.feature.home.generated.resources.feature_home_arrow_up +import mobile_wallet.feature.home.generated.resources.feature_home_autopay import mobile_wallet.feature.home.generated.resources.feature_home_coin_image import mobile_wallet.feature.home.generated.resources.feature_home_desc import mobile_wallet.feature.home.generated.resources.feature_home_loading @@ -124,6 +125,7 @@ internal fun HomeScreen( onNavigateBack: () -> Unit, onRequest: (String) -> Unit, onPay: () -> Unit, + onAutoPay: () -> Unit, navigateToTransactionDetail: (Long, Long) -> Unit, navigateToAccountDetail: (Long) -> Unit, modifier: Modifier = Modifier, @@ -141,6 +143,7 @@ internal fun HomeScreen( is HomeEvent.NavigateBack -> onNavigateBack.invoke() is HomeEvent.NavigateToRequestScreen -> onRequest(event.vpa) is HomeEvent.NavigateToSendScreen -> onPay.invoke() + is HomeEvent.NavigateToAutoPayScreen -> onAutoPay.invoke() is HomeEvent.NavigateToClientDetailScreen -> {} is HomeEvent.NavigateToTransactionDetail -> { navigateToTransactionDetail(event.accountId, event.transactionId) @@ -277,6 +280,9 @@ private fun HomeScreenContent( onSend = { onAction(HomeAction.SendClicked) }, + onAutoPay = { + onAction(HomeAction.AutoPayClicked) + }, ) } @@ -507,45 +513,66 @@ fun CardDropdownBox( private fun PayRequestScreen( onRequest: () -> Unit, onSend: () -> Unit, + onAutoPay: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + Column( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - PaymentButton( - modifier = Modifier - .weight(1f) - .height(55.dp), - text = stringResource(Res.string.feature_home_request), - onClick = onRequest, - leadingIcon = { - Icon( - modifier = Modifier - .size(26.dp), - imageVector = vectorResource( - Res.drawable.arrow_backward, - ), - contentDescription = stringResource(Res.string.feature_home_request_money), - ) - }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PaymentButton( + modifier = Modifier + .weight(1f) + .height(55.dp), + text = stringResource(Res.string.feature_home_request), + onClick = onRequest, + leadingIcon = { + Icon( + modifier = Modifier + .size(26.dp), + imageVector = vectorResource( + Res.drawable.arrow_backward, + ), + contentDescription = stringResource(Res.string.feature_home_request_money), + ) + }, + ) + + Spacer(modifier = Modifier.width(20.dp)) - Spacer(modifier = Modifier.width(20.dp)) + PaymentButton( + modifier = Modifier + .weight(1f) + .height(55.dp), + text = stringResource(Res.string.feature_home_send), + onClick = onSend, + leadingIcon = { + Icon( + modifier = Modifier + .size(26.dp) + .graphicsLayer(rotationZ = 180f), + imageVector = vectorResource(Res.drawable.arrow_backward), + contentDescription = stringResource(Res.string.feature_home_send_money), + ) + }, + ) + } PaymentButton( modifier = Modifier - .weight(1f) + .fillMaxWidth() .height(55.dp), - text = stringResource(Res.string.feature_home_send), - onClick = onSend, + text = stringResource(Res.string.feature_home_autopay), + onClick = onAutoPay, leadingIcon = { Icon( - modifier = Modifier - .size(26.dp) - .graphicsLayer(rotationZ = 180f), - imageVector = vectorResource(Res.drawable.arrow_backward), - contentDescription = stringResource(Res.string.feature_home_send_money), + modifier = Modifier.size(26.dp), + imageVector = MifosIcons.Payment, + contentDescription = stringResource(Res.string.feature_home_autopay), ) }, ) diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt index 35b00c469..68db98948 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt @@ -117,6 +117,10 @@ class HomeViewModel( sendEvent(HomeEvent.NavigateToSendScreen) } + is HomeAction.AutoPayClicked -> { + sendEvent(HomeEvent.NavigateToAutoPayScreen) + } + is HomeAction.ClientDetailsClicked -> { sendEvent(HomeEvent.NavigateToClientDetailScreen) } @@ -218,6 +222,7 @@ sealed interface ViewState { sealed interface HomeEvent { data object NavigateBack : HomeEvent data object NavigateToSendScreen : HomeEvent + data object NavigateToAutoPayScreen : HomeEvent data object NavigateToTransactionScreen : HomeEvent data object NavigateToClientDetailScreen : HomeEvent data class NavigateToRequestScreen(val vpa: String) : HomeEvent @@ -230,6 +235,7 @@ sealed interface HomeEvent { sealed interface HomeAction { data object RequestClicked : HomeAction data object SendClicked : HomeAction + data object AutoPayClicked : HomeAction data object ClientDetailsClicked : HomeAction data object OnClickSeeAllTransactions : HomeAction data object OnDismissDialog : HomeAction diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt index 5ea8e9776..e028a5192 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt @@ -23,6 +23,7 @@ fun NavGraphBuilder.homeScreen( onNavigateBack: () -> Unit, onRequest: (String) -> Unit, onPay: () -> Unit, + onAutoPay: () -> Unit, navigateToTransactionDetail: (Long, Long) -> Unit, navigateToAccountDetail: (Long) -> Unit, ) { @@ -30,6 +31,7 @@ fun NavGraphBuilder.homeScreen( HomeScreen( onRequest = onRequest, onPay = onPay, + onAutoPay = onAutoPay, onNavigateBack = onNavigateBack, navigateToTransactionDetail = navigateToTransactionDetail, navigateToAccountDetail = navigateToAccountDetail, diff --git a/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt b/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt index 66263af9c..59e9b02bd 100644 --- a/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt +++ b/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt @@ -62,6 +62,7 @@ enum class PaymentsScreenContents { HISTORY, SI, INVOICES, + AUTOPAY, } @Preview diff --git a/settings.gradle.kts b/settings.gradle.kts index 63c88a74f..bc520718e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -83,5 +83,6 @@ include(":feature:payments") include(":feature:request-money") include(":feature:upi-setup") include(":feature:qr") +include(":feature:autopay") include(":libs:mifos-passcode") From 7539965f23dfc0d03e1257d2e47fd9b4997ab4ff Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Sun, 24 Aug 2025 11:28:40 +0530 Subject: [PATCH 08/10] feat(feature:autopay): add autopay to send money options --- .../shared/navigation/MifosNavHost.kt | 3 +++ .../composeResources/values/strings.xml | 1 + .../send/money/SendMoneyOptionsScreen.kt | 24 +++++++++++++++++++ .../send/money/SendMoneyOptionsViewModel.kt | 5 ++++ .../send/money/navigation/SendNavigation.kt | 2 ++ 5 files changed, 35 insertions(+) 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 3ed7df5b0..9adc2ca65 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -325,6 +325,9 @@ internal fun MifosNavHost( onFineractPaymentsClick = { navController.navigateToSendMoneyScreen() }, + onAutoPayClick = { + navController.navigateToAutoPay() + }, onQrCodeScanned = { qrData -> navController.navigateToSendMoneyScreen( requestData = qrData, diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index ccdfcdb74..a1ea3ebb2 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -48,4 +48,5 @@ People Merchants More + AutoPay \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt index aaa28a8c1..71db8acbe 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_autopay import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer import mobile_wallet.feature.send_money.generated.resources.feature_send_money_choose_method import mobile_wallet.feature.send_money.generated.resources.feature_send_money_fineract_payments @@ -66,6 +67,7 @@ fun SendMoneyOptionsScreen( onPayAnyoneClick: () -> Unit, onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, onQrCodeScanned: (String) -> Unit, onNavigateToPayeeDetails: (String) -> Unit, modifier: Modifier = Modifier, @@ -87,6 +89,9 @@ fun SendMoneyOptionsScreen( SendMoneyOptionsEvent.NavigateToFineractPayments -> { onFineractPaymentsClick.invoke() } + SendMoneyOptionsEvent.NavigateToAutoPay -> { + onAutoPayClick.invoke() + } is SendMoneyOptionsEvent.QrCodeScanned -> { onQrCodeScanned.invoke(event.data) } @@ -132,6 +137,9 @@ fun SendMoneyOptionsScreen( onFineractPaymentsClick = { viewModel.trySendAction(SendMoneyOptionsAction.FineractPaymentsClicked) }, + onAutoPayClick = { + viewModel.trySendAction(SendMoneyOptionsAction.AutoPayClicked) + }, ) Spacer(modifier = Modifier.height(KptTheme.spacing.md)) @@ -182,6 +190,7 @@ private fun SendMoneyOptionsRow( onPayAnyoneClick: () -> Unit, onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -220,6 +229,21 @@ private fun SendMoneyOptionsRow( modifier = Modifier.weight(1f), ) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.CalenderMonth, + label = stringResource(Res.string.feature_send_money_autopay), + onClick = onAutoPayClick, + modifier = Modifier.weight(1f), + ) + + // Empty space for future icons (UPI Lite, Tap & Pay, etc.) + Spacer(modifier = Modifier.weight(3f)) + } } } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt index 0e82e041e..0df5f00f6 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -51,6 +51,9 @@ class SendMoneyOptionsViewModel( is SendMoneyOptionsAction.FineractPaymentsClicked -> { sendEvent(SendMoneyOptionsEvent.NavigateToFineractPayments) } + is SendMoneyOptionsAction.AutoPayClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToAutoPay) + } } } } @@ -64,6 +67,7 @@ sealed interface SendMoneyOptionsEvent { data object NavigateToPayAnyone : SendMoneyOptionsEvent data object NavigateToBankTransfer : SendMoneyOptionsEvent data object NavigateToFineractPayments : SendMoneyOptionsEvent + data object NavigateToAutoPay : SendMoneyOptionsEvent data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent, BackgroundEvent data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyOptionsEvent, BackgroundEvent } @@ -74,4 +78,5 @@ sealed interface SendMoneyOptionsAction { data object PayAnyoneClicked : SendMoneyOptionsAction data object BankTransferClicked : SendMoneyOptionsAction data object FineractPaymentsClicked : SendMoneyOptionsAction + data object AutoPayClicked : SendMoneyOptionsAction } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 00962212e..74defd7a4 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -83,6 +83,7 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( onPayAnyoneClick: () -> Unit, onBankTransferClick: () -> Unit, onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, onQrCodeScanned: (String) -> Unit, onNavigateToPayeeDetails: (String) -> Unit, ) { @@ -95,6 +96,7 @@ fun NavGraphBuilder.sendMoneyOptionsScreen( onPayAnyoneClick = onPayAnyoneClick, onBankTransferClick = onBankTransferClick, onFineractPaymentsClick = onFineractPaymentsClick, + onAutoPayClick = onAutoPayClick, onQrCodeScanned = onQrCodeScanned, onNavigateToPayeeDetails = onNavigateToPayeeDetails, ) From f8844fd483ed48b1f3c666f58baf2e05f6d9a85d Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Sun, 24 Aug 2025 13:49:59 +0530 Subject: [PATCH 09/10] feat(feature:autopay): implement autopay dashboard screen --- .../shared/navigation/MifosNavHost.kt | 4 + .../core/designsystem/icon/MifosIcons.kt | 19 + feature/autopay/README.md | 100 +++- .../feature/autopay/AutoPayHistoryScreen.kt | 231 ++++++- .../feature/autopay/AutoPayNavigation.kt | 24 + .../autopay/AutoPayPreferencesScreen.kt | 199 +++++- .../feature/autopay/AutoPayRulesScreen.kt | 158 ++++- .../autopay/AutoPayScheduleDetailsScreen.kt | 434 ++++++++++++++ .../AutoPayScheduleDetailsViewModel.kt | 148 +++++ .../mifospay/feature/autopay/AutoPayScreen.kt | 564 ++++++++++++++++-- .../feature/autopay/AutoPaySetupScreen.kt | 112 +++- .../feature/autopay/AutoPayViewModel.kt | 184 +++++- .../feature/autopay/di/AutoPayModule.kt | 2 + 13 files changed, 2058 insertions(+), 121 deletions(-) create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt create mode 100644 feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt 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 9adc2ca65..d5fec4417 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 @@ -28,6 +28,7 @@ import org.mifospay.feature.autopay.navigateToAutoPay import org.mifospay.feature.autopay.navigateToAutoPayHistory import org.mifospay.feature.autopay.navigateToAutoPayPreferences import org.mifospay.feature.autopay.navigateToAutoPayRules +import org.mifospay.feature.autopay.navigateToAutoPayScheduleDetails import org.mifospay.feature.autopay.navigateToAutoPaySetup import org.mifospay.feature.editpassword.navigation.editPasswordScreen import org.mifospay.feature.editpassword.navigation.navigateToEditPassword @@ -148,6 +149,9 @@ internal fun MifosNavHost( onNavigateToHistory = { navController.navigateToAutoPayHistory() }, + onNavigateToScheduleDetails = { scheduleId -> + navController.navigateToAutoPayScheduleDetails(scheduleId) + }, ) }, ) 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 6c9ac6f67..a752faea3 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 @@ -12,6 +12,7 @@ package org.mifospay.core.designsystem.icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Rule import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.AttachMoney @@ -19,14 +20,17 @@ import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle 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.CreditCard 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.Error import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.filled.History @@ -35,13 +39,18 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material.icons.filled.Power 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.Rule +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.DeleteOutline @@ -137,4 +146,14 @@ object MifosIcons { val CurrencyRupee = Icons.Filled.CurrencyRupee val History = Icons.Filled.History + val CheckCircle = Icons.Filled.CheckCircle + val Error = Icons.Filled.Error + + // AutoPay specific icons + val Warning = Icons.Filled.Warning + val Schedule = Icons.Filled.Schedule + val Security = Icons.Filled.Security + val Power = Icons.Filled.Power + val CreditCard = Icons.Filled.CreditCard + val Rule = Icons.AutoMirrored.Filled.Rule } diff --git a/feature/autopay/README.md b/feature/autopay/README.md index f2fce2b91..1f0e4403d 100644 --- a/feature/autopay/README.md +++ b/feature/autopay/README.md @@ -4,11 +4,46 @@ The AutoPay feature module provides functionality for setting up and managing automatic payment schedules. This module allows users to configure recurring payments, set up payment rules, and manage their automatic payment preferences. ## Features +- **AutoPay Dashboard**: Main screen displaying active schedules, upcoming payments, and quick actions - Set up recurring payment schedules - Configure payment rules and conditions - Manage automatic payment preferences - View payment history and status - Enable/disable automatic payments +- Pull-to-refresh functionality +- Schedule details view with management actions + +## AutoPay Dashboard + +The main AutoPay Dashboard screen provides a comprehensive view of all AutoPay activities: + +### Dashboard Components +- **Dashboard Header**: Shows total active schedules and upcoming payments with visual statistics +- **Quick Actions**: Add new schedule and manage existing schedules buttons +- **Active Schedules**: List of all active AutoPay schedules with status indicators +- **Upcoming Payments**: List of scheduled payments with due dates +- **Pull-to-Refresh**: Swipe down to refresh dashboard data +- **Loading States**: Proper loading indicators during data fetch +- **Empty States**: Helpful messages when no schedules or payments exist + +### Schedule Information Displayed +- Schedule name and recipient +- Payment amount and currency +- Frequency (Monthly, Weekly, etc.) +- Next payment date +- Status (Active, Paused, Cancelled, Completed) +- Account number (masked for security) + +### Quick Actions +- **Add New**: Navigate to schedule setup screen +- **Manage**: Navigate to rules and preferences management +- **View Details**: Tap on any schedule to see detailed information + +### Schedule Details Screen +When a user taps on an active schedule, they can view: +- Complete schedule information +- Payment details +- Schedule management actions (Pause/Resume, Edit, Cancel) ## Screenshots ### Android @@ -28,7 +63,8 @@ feature/autopay/ │ │ ├── kotlin/org/mifospay/feature/autopay/ │ │ │ ├── di/ │ │ │ │ └── AutoPayModule.kt -│ │ │ ├── AutoPayScreen.kt +│ │ │ ├── AutoPayScreen.kt (Dashboard) +│ │ │ ├── AutoPayScheduleDetailsScreen.kt │ │ │ ├── AutoPayNavigation.kt │ │ │ └── AutoPayViewModel.kt │ │ └── composeResources/ @@ -38,6 +74,47 @@ feature/autopay/ └── README.md ``` +## Data Models + +### AutoPaySchedule +```kotlin +data class AutoPaySchedule( + val id: String, + val name: String, + val amount: Double, + val currency: String, + val frequency: String, + val nextPaymentDate: String, + val status: AutoPayStatus, + val recipientName: String, + val accountNumber: String, +) +``` + +### UpcomingPayment +```kotlin +data class UpcomingPayment( + val id: String, + val scheduleName: String, + val amount: Double, + val currency: String, + val dueDate: String, + val status: PaymentStatus, + val recipientName: String, +) +``` + +### Status Enums +```kotlin +enum class AutoPayStatus { + ACTIVE, PAUSED, CANCELLED, COMPLETED +} + +enum class PaymentStatus { + UPCOMING, PROCESSING, COMPLETED, FAILED +} +``` + ## Dependencies - Compose UI components - Material3 design system @@ -47,5 +124,24 @@ feature/autopay/ ## Usage This module is designed to be integrated into the main application through dependency injection. The AutoPayModule provides the necessary dependencies for the AutoPay feature. +### Navigation +The AutoPay feature includes the following navigation routes: +- `autopay` - Main dashboard +- `autopay/setup` - Setup new schedule +- `autopay/rules` - Manage rules +- `autopay/preferences` - Manage preferences +- `autopay/history` - View history +- `autopay/schedule/{scheduleId}` - Schedule details + ## Development Status -🚧 **In Development** - Basic module structure created +✅ **Dashboard Implementation Complete** - AutoPay Dashboard with all required features implemented +- ✅ Display list of active AutoPay schedules +- ✅ Show upcoming payments with due dates +- ✅ Display quick action buttons (Add New, Manage Existing) +- ✅ Show payment status indicators +- ✅ Implement pull-to-refresh functionality +- ✅ Handle loading and error states +- ✅ Schedule details screen +- ✅ Dummy data for demonstration + +🚧 **Additional Features** - Setup, Rules, Preferences, and History screens need implementation diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt index 85af684c2..ef2359b13 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt @@ -11,44 +11,237 @@ package org.mifospay.feature.autopay import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons @Composable fun AutoPayHistoryScreen( onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay History", + backPress = onNavigateBack, + ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() + .padding(paddingValues) .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { - Text( - text = "AutoPay History", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - Text( - text = "View your automatic payment history", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(bottom = 16.dp), + ) { + Icon( + imageVector = MifosIcons.History, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "AutoPay History", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "View your AutoPay transaction history and activities.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(getDummyHistoryItems()) { historyItem -> + HistoryItemCard(historyItem = historyItem) + } + } } } } + +@Composable +private fun HistoryItemCard( + historyItem: AutoPayHistoryItem, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = historyItem.icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = historyItem.statusColor, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = historyItem.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = historyItem.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Column( + horizontalAlignment = Alignment.End, + ) { + Text( + text = historyItem.amount, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = historyItem.date, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (historyItem.status != null) { + Spacer(modifier = Modifier.height(8.dp)) + + Divider() + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Status: ${historyItem.status}", + style = MaterialTheme.typography.bodySmall, + color = historyItem.statusColor, + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +private data class AutoPayHistoryItem( + val title: String, + val description: String, + val amount: String, + val date: String, + val status: String?, + val icon: ImageVector, + val statusColor: Color, +) + +private fun getDummyHistoryItems(): List { + return listOf( + AutoPayHistoryItem( + title = "Monthly Rent Payment", + description = "AutoPay to Landlord Corp", + amount = "$1,200.00", + date = "Jan 15, 2024", + status = "Completed", + icon = MifosIcons.CheckCircle, + statusColor = Color.Green, + ), + AutoPayHistoryItem( + title = "Internet Bill", + description = "AutoPay to Comcast", + amount = "$89.99", + date = "Jan 10, 2024", + status = "Completed", + icon = MifosIcons.CheckCircle, + statusColor = Color.Green, + ), + AutoPayHistoryItem( + title = "Electricity Bill", + description = "AutoPay to Power Company", + amount = "$156.75", + date = "Jan 5, 2024", + status = "Failed", + icon = MifosIcons.Error, + statusColor = Color.Red, + ), + AutoPayHistoryItem( + title = "Phone Bill", + description = "AutoPay to Verizon", + amount = "$85.50", + date = "Dec 28, 2023", + status = "Completed", + icon = MifosIcons.CheckCircle, + statusColor = Color.Green, + ), + AutoPayHistoryItem( + title = "Gym Membership", + description = "AutoPay to Fitness Center", + amount = "$45.00", + date = "Dec 20, 2023", + status = "Completed", + icon = MifosIcons.CheckCircle, + statusColor = Color.Green, + ), + AutoPayHistoryItem( + title = "Schedule Created", + description = "New AutoPay schedule for Netflix", + amount = "$15.99/month", + date = "Dec 15, 2023", + status = null, + icon = MifosIcons.Add, + statusColor = Color.Blue, + ), + AutoPayHistoryItem( + title = "Schedule Cancelled", + description = "AutoPay schedule for Spotify cancelled", + amount = "$9.99/month", + date = "Dec 10, 2023", + status = null, + icon = MifosIcons.Cancel, + statusColor = Color.Yellow, + ), + ) +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt index 92a53011d..888509fbc 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt @@ -12,7 +12,9 @@ package org.mifospay.feature.autopay import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.navArgument import androidx.navigation.navOptions object AutoPayNavigation { @@ -21,6 +23,7 @@ object AutoPayNavigation { const val AUTO_PAY_RULES_ROUTE = "autopay/rules" const val AUTO_PAY_PREFERENCES_ROUTE = "autopay/preferences" const val AUTO_PAY_HISTORY_ROUTE = "autopay/history" + const val AUTO_PAY_SCHEDULE_DETAILS_ROUTE = "autopay/schedule/{scheduleId}" } fun NavController.navigateToAutoPay(navOptions: NavOptions? = null) { @@ -43,6 +46,10 @@ fun NavController.navigateToAutoPayHistory(navOptions: NavOptions? = null) { navigate(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE, navOptions) } +fun NavController.navigateToAutoPayScheduleDetails(scheduleId: String, navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE.replace("{scheduleId}", scheduleId), navOptions) +} + fun NavGraphBuilder.autoPayGraph( navController: NavController, onNavigateBack: () -> Unit = { navController.navigateUp() }, @@ -61,6 +68,9 @@ fun NavGraphBuilder.autoPayGraph( onNavigateToHistory = { navController.navigateToAutoPayHistory() }, + onNavigateToScheduleDetails = { scheduleId -> + navController.navigateToAutoPayScheduleDetails(scheduleId) + }, ) } @@ -87,4 +97,18 @@ fun NavGraphBuilder.autoPayGraph( onNavigateBack = onNavigateBack, ) } + + composable( + route = AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE, + arguments = listOf( + navArgument("scheduleId") { + type = NavType.StringType + nullable = false + }, + ), + ) { + AutoPayScheduleDetailsScreen( + onNavigateBack = onNavigateBack, + ) + } } diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt index 2b2330fb4..99f097922 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt @@ -11,44 +11,215 @@ package org.mifospay.feature.autopay import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.Switch 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.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons @Composable fun AutoPayPreferencesScreen( onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay Preferences", + backPress = onNavigateBack, + ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = MifosIcons.Settings, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "AutoPay Preferences", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "Customize your AutoPay experience and notification settings.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + PreferencesSection( + title = "Notifications", + preferences = listOf( + PreferenceItem( + title = "Payment Confirmations", + description = "Receive notifications when payments are processed", + icon = MifosIcons.OutlinedNotifications, + ), + PreferenceItem( + title = "Failed Payment Alerts", + description = "Get notified when payments fail", + icon = MifosIcons.Warning, + ), + PreferenceItem( + title = "Schedule Reminders", + description = "Receive reminders before scheduled payments", + icon = MifosIcons.Schedule, + ), + ), + ) + + PreferencesSection( + title = "Security", + preferences = listOf( + PreferenceItem( + title = "Two-Factor Authentication", + description = "Require 2FA for AutoPay changes", + icon = MifosIcons.Security, + ), + PreferenceItem( + title = "Payment Limits", + description = "Set maximum payment amounts", + icon = MifosIcons.AttachMoney, + ), + ), + ) + + PreferencesSection( + title = "General", + preferences = listOf( + PreferenceItem( + title = "AutoPay Enabled", + description = "Enable or disable AutoPay functionality", + icon = MifosIcons.Power, + ), + PreferenceItem( + title = "Default Payment Method", + description = "Set your preferred payment method", + icon = MifosIcons.CreditCard, + ), + ), + ) + } + } +} + +@Composable +private fun PreferencesSection( + title: String, + preferences: List, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxSize(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + preferences.forEach { preference -> + PreferenceRow(preference = preference) + } + } + } + } +} + +@Composable +private fun PreferenceRow( + preference: PreferenceItem, + modifier: Modifier = Modifier, +) { + var isEnabled by remember { mutableStateOf(true) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = preference.icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), ) { Text( - text = "AutoPay Preferences", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, + text = preference.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, ) Text( - text = "Customize your AutoPay settings", - style = MaterialTheme.typography.bodyLarge, + text = preference.description, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), ) } + + Switch( + checked = isEnabled, + onCheckedChange = { isEnabled = it }, + ) } } + +private data class PreferenceItem( + val title: String, + val description: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, +) diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt index 3106a196b..6a5e150b2 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt @@ -11,44 +11,170 @@ package org.mifospay.feature.autopay import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons @Composable fun AutoPayRulesScreen( onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay Rules", + backPress = onNavigateBack, + ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - text = "AutoPay Rules", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = MifosIcons.Rule, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "AutoPay Rules & Policies", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "Understand how AutoPay works and the rules that govern automatic payments.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + RulesSection( + title = "General Rules", + rules = listOf( + "AutoPay schedules can be set up for recurring payments", + "Maximum payment amount is limited to $10,000 per transaction", + "Payments are processed on the scheduled date", + "Failed payments will be retried up to 3 times", + "You can pause or cancel schedules at any time", + ), ) + RulesSection( + title = "Frequency Limits", + rules = listOf( + "Daily: Maximum 1 payment per day", + "Weekly: Maximum 2 payments per week", + "Monthly: Maximum 4 payments per month", + "Yearly: Maximum 12 payments per year", + ), + ) + + RulesSection( + title = "Security & Privacy", + rules = listOf( + "All payment data is encrypted and secure", + "You will receive notifications for all AutoPay activities", + "You can view payment history and status anytime", + "AutoPay can be disabled temporarily or permanently", + ), + ) + + RulesSection( + title = "Cancellation Policy", + rules = listOf( + "Schedules can be cancelled before the next payment date", + "Cancelled schedules cannot be reactivated", + "You must create a new schedule after cancellation", + "No fees are charged for cancelling AutoPay schedules", + ), + ) + } + } +} + +@Composable +private fun RulesSection( + title: String, + rules: List, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxSize(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Text( - text = "Manage your payment conditions and schedules", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + rules.forEach { rule -> + RuleItem(rule = rule) + } + } } } } + +@Composable +private fun RuleItem( + rule: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Top, + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(end = 8.dp), + ) + + Text( + text = rule, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt new file mode 100644 index 000000000..e0687b9fd --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt @@ -0,0 +1,434 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +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.utils.EventsEffect + +@Composable +fun AutoPayScheduleDetailsScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AutoPayScheduleDetailsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + MifosScaffold( + modifier = modifier, + topBarTitle = "Schedule Details", + backPress = onNavigateBack, + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (state.schedule == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Schedule Not Found", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "The requested AutoPay schedule could not be found.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + ScheduleDetailsContent( + schedule = state.schedule!!, + modifier = Modifier.padding(paddingValues), + onPauseResume = { + if (state.schedule!!.status == AutoPayStatus.ACTIVE) { + viewModel.trySendAction(AutoPayScheduleDetailsAction.PauseSchedule) + } else { + viewModel.trySendAction(AutoPayScheduleDetailsAction.ResumeSchedule) + } + }, + onEdit = { viewModel.trySendAction(AutoPayScheduleDetailsAction.EditSchedule) }, + onCancel = { viewModel.trySendAction(AutoPayScheduleDetailsAction.CancelSchedule) }, + ) + } + } + + EventsEffect(viewModel) { event -> + when (event) { + is AutoPayScheduleDetailsEvent.NavigateBack -> onNavigateBack() + is AutoPayScheduleDetailsEvent.SchedulePaused -> { /* TODO: Show success message */ } + is AutoPayScheduleDetailsEvent.ScheduleResumed -> { /* TODO: Show success message */ } + is AutoPayScheduleDetailsEvent.ScheduleCancelled -> { /* TODO: Show success message */ } + is AutoPayScheduleDetailsEvent.NavigateToEdit -> { /* TODO: Navigate to edit screen */ } + } + } +} + +@Composable +private fun ScheduleDetailsContent( + schedule: AutoPaySchedule, + onPauseResume: () -> Unit, + onEdit: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ScheduleHeaderCard(schedule = schedule) + + ScheduleInfoCard(schedule = schedule) + + PaymentDetailsCard(schedule = schedule) + + ScheduleActionsCard( + schedule = schedule, + onPauseResume = onPauseResume, + onEdit = onEdit, + onCancel = onCancel, + ) + } +} + +@Composable +private fun ScheduleHeaderCard( + schedule: AutoPaySchedule, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = schedule.name, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + StatusChip(status = schedule.status) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Text( + text = "per ${schedule.frequency.lowercase()}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun ScheduleInfoCard( + schedule: AutoPaySchedule, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Schedule Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InfoRow( + label = "Recipient", + value = schedule.recipientName, + icon = MifosIcons.Person, + ) + + InfoRow( + label = "Account Number", + value = schedule.accountNumber, + icon = MifosIcons.Bank, + ) + + InfoRow( + label = "Frequency", + value = schedule.frequency, + icon = MifosIcons.CalenderMonth, + ) + + InfoRow( + label = "Next Payment", + value = schedule.nextPaymentDate, + icon = MifosIcons.CalenderMonth, + ) + } + } +} + +@Composable +private fun PaymentDetailsCard( + schedule: AutoPaySchedule, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Payment Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InfoRow( + label = "Amount", + value = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), + icon = MifosIcons.AttachMoney, + ) + + InfoRow( + label = "Status", + value = schedule.status.name, + icon = MifosIcons.Info, + ) + } + } +} + +@Composable +private fun ScheduleActionsCard( + schedule: AutoPaySchedule, + onPauseResume: () -> Unit, + onEdit: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ActionButton( + text = if (schedule.status == AutoPayStatus.ACTIVE) "Pause" else "Resume", + icon = if (schedule.status == AutoPayStatus.ACTIVE) MifosIcons.FlashOff else MifosIcons.FlashOn, + onClick = onPauseResume, + modifier = Modifier.weight(1f), + ) + + ActionButton( + text = "Edit", + icon = MifosIcons.Edit, + onClick = onEdit, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + ActionButton( + text = "Cancel Schedule", + icon = MifosIcons.Delete, + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +private fun ActionButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + + Spacer(modifier = Modifier.size(8.dp)) + + Text(text = text) + } +} + +@Composable +private fun StatusChip( + status: AutoPayStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + AutoPayStatus.ACTIVE -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + AutoPayStatus.PAUSED -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + AutoPayStatus.CANCELLED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + AutoPayStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt new file mode 100644 index 000000000..8fb3c46d2 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayScheduleDetailsViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AutoPayScheduleDetailsState( + scheduleId = requireNotNull(savedStateHandle.get("scheduleId")), + ), +) { + + companion object { + private const val KEY_STATE = "autopay_schedule_details_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadScheduleDetails() + } + + override fun handleAction(action: AutoPayScheduleDetailsAction) { + when (action) { + is AutoPayScheduleDetailsAction.PauseSchedule -> { + pauseSchedule() + } + is AutoPayScheduleDetailsAction.ResumeSchedule -> { + resumeSchedule() + } + is AutoPayScheduleDetailsAction.EditSchedule -> { + editSchedule() + } + is AutoPayScheduleDetailsAction.CancelSchedule -> { + cancelSchedule() + } + is AutoPayScheduleDetailsAction.NavigateBack -> { + sendEvent(AutoPayScheduleDetailsEvent.NavigateBack) + } + } + } + + private fun loadScheduleDetails() { + mutableStateFlow.update { it.copy(isLoading = true) } + + // Simulate API call delay + viewModelScope.launch { + delay(500) + + // For now, we'll use dummy data + // In a real implementation, this would fetch from a repository + val dummySchedule = AutoPaySchedule( + id = state.scheduleId, + name = "Monthly Rent Payment", + amount = 1200.0, + currency = "USD", + frequency = "Monthly", + nextPaymentDate = "2024-02-15", + status = AutoPayStatus.ACTIVE, + recipientName = "Landlord Corp", + accountNumber = "****1234", + ) + + mutableStateFlow.update { + it.copy( + isLoading = false, + schedule = dummySchedule, + ) + } + } + } + + private fun pauseSchedule() { + mutableStateFlow.update { + it.copy( + schedule = it.schedule?.copy(status = AutoPayStatus.PAUSED), + ) + } + sendEvent(AutoPayScheduleDetailsEvent.SchedulePaused) + } + + private fun resumeSchedule() { + mutableStateFlow.update { + it.copy( + schedule = it.schedule?.copy(status = AutoPayStatus.ACTIVE), + ) + } + sendEvent(AutoPayScheduleDetailsEvent.ScheduleResumed) + } + + private fun editSchedule() { + sendEvent(AutoPayScheduleDetailsEvent.NavigateToEdit(state.scheduleId)) + } + + private fun cancelSchedule() { + mutableStateFlow.update { + it.copy( + schedule = it.schedule?.copy(status = AutoPayStatus.CANCELLED), + ) + } + sendEvent(AutoPayScheduleDetailsEvent.ScheduleCancelled) + } +} + +@Serializable +data class AutoPayScheduleDetailsState( + val scheduleId: String, + val isLoading: Boolean = false, + val schedule: AutoPaySchedule? = null, + val error: String? = null, +) + +sealed interface AutoPayScheduleDetailsEvent { + data object NavigateBack : AutoPayScheduleDetailsEvent + data object SchedulePaused : AutoPayScheduleDetailsEvent + data object ScheduleResumed : AutoPayScheduleDetailsEvent + data object ScheduleCancelled : AutoPayScheduleDetailsEvent + data class NavigateToEdit(val scheduleId: String) : AutoPayScheduleDetailsEvent +} + +sealed interface AutoPayScheduleDetailsAction { + data object PauseSchedule : AutoPayScheduleDetailsAction + data object ResumeSchedule : AutoPayScheduleDetailsAction + data object EditSchedule : AutoPayScheduleDetailsAction + data object CancelSchedule : AutoPayScheduleDetailsAction + data object NavigateBack : AutoPayScheduleDetailsAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt index bdb35b727..345ba5f4c 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt @@ -10,28 +10,39 @@ package org.mifospay.feature.autopay 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.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +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.component.rememberMifosPullToRefreshState import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect @Composable fun AutoPayScreen( @@ -39,72 +50,304 @@ fun AutoPayScreen( onNavigateToRules: () -> Unit, onNavigateToPreferences: () -> Unit, onNavigateToHistory: () -> Unit, + onNavigateToScheduleDetails: (String) -> Unit, modifier: Modifier = Modifier, viewModel: AutoPayViewModel = koinViewModel(), ) { - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val pullRefreshState = rememberMifosPullToRefreshState( + isEnabled = true, + isRefreshing = state.isLoading, + onRefresh = { viewModel.trySendAction(AutoPayAction.RefreshDashboard) }, + ) + + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay Dashboard", + backPress = { /* Handle back navigation */ }, + pullToRefreshState = pullRefreshState, + ) { paddingValues -> + if (state.isLoading && state.activeSchedules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + AutoPayDashboardContent( + state = state, + onRefresh = { viewModel.trySendAction(AutoPayAction.RefreshDashboard) }, + onAddNewSchedule = { viewModel.trySendAction(AutoPayAction.AddNewSchedule) }, + onManageSchedules = { viewModel.trySendAction(AutoPayAction.ManageExistingSchedules) }, + onViewScheduleDetails = { scheduleId -> + viewModel.trySendAction(AutoPayAction.ViewScheduleDetails(scheduleId)) + }, + onNavigateToSetup = onNavigateToSetup, + onNavigateToRules = onNavigateToRules, + onNavigateToPreferences = onNavigateToPreferences, + onNavigateToHistory = onNavigateToHistory, + onNavigateToScheduleDetails = onNavigateToScheduleDetails, + modifier = Modifier.padding(paddingValues), + ) + } + } + + EventsEffect(viewModel) { event -> + when (event) { + is AutoPayEvent.NavigateToSetup -> onNavigateToSetup() + is AutoPayEvent.NavigateToRules -> onNavigateToRules() + is AutoPayEvent.NavigateToPreferences -> onNavigateToPreferences() + is AutoPayEvent.NavigateToHistory -> onNavigateToHistory() + is AutoPayEvent.NavigateToScheduleDetails -> onNavigateToScheduleDetails(event.scheduleId) + } + } +} + +@Composable +private fun AutoPayDashboardContent( + state: AutoPayState, + onRefresh: () -> Unit, + onAddNewSchedule: () -> Unit, + onManageSchedules: () -> Unit, + onViewScheduleDetails: (String) -> Unit, + onNavigateToSetup: () -> Unit, + onNavigateToRules: () -> Unit, + onNavigateToPreferences: () -> Unit, + onNavigateToHistory: () -> Unit, + onNavigateToScheduleDetails: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + DashboardHeader( + totalActiveSchedules = state.totalActiveSchedules, + totalUpcomingPayments = state.totalUpcomingPayments, + ) + } + + item { + QuickActionsSection( + onAddNewSchedule = onAddNewSchedule, + onManageSchedules = onManageSchedules, + ) + } + + item { + Text( + text = "Active Schedules", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + + if (state.activeSchedules.isEmpty()) { + item { + EmptyStateCard( + title = "No Active Schedules", + description = "You don't have any active AutoPay schedules. Create one to get started!", + icon = MifosIcons.Payment, + ) + } + } else { + items(state.activeSchedules) { schedule -> + ActiveScheduleCard( + schedule = schedule, + onClick = { onViewScheduleDetails(schedule.id) }, + ) + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Upcoming Payments", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + + if (state.upcomingPayments.isEmpty()) { + item { + EmptyStateCard( + title = "No Upcoming Payments", + description = "No payments are scheduled for the near future.", + icon = MifosIcons.CalenderMonth, + ) + } + } else { + items(state.upcomingPayments) { payment -> + UpcomingPaymentCard( + payment = payment, + ) + } + } + } +} + +@Composable +private fun DashboardHeader( + totalActiveSchedules: Int, + totalUpcomingPayments: Int, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = "AutoPay", + text = "AutoPay Dashboard", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, ) - Text( - text = "Manage your automatic payments", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.size(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + DashboardStat( + label = "Active Schedules", + value = totalActiveSchedules.toString(), + icon = MifosIcons.Payment, + ) - AutoPayOptionCard( - title = "Setup AutoPay", - description = "Configure automatic payment rules", - icon = MifosIcons.Settings, - onClick = onNavigateToSetup, - ) + DashboardStat( + label = "Upcoming Payments", + value = totalUpcomingPayments.toString(), + icon = MifosIcons.CalenderMonth, + ) + } + } + } +} - AutoPayOptionCard( - title = "Payment Rules", - description = "Manage payment conditions and schedules", - icon = MifosIcons.Payment, - onClick = onNavigateToRules, - ) +@Composable +private fun DashboardStat( + label: String, + value: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) - AutoPayOptionCard( - title = "Preferences", - description = "Customize AutoPay settings", - icon = MifosIcons.Profile, - onClick = onNavigateToPreferences, - ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } +} - AutoPayOptionCard( - title = "History", - description = "View AutoPay transaction history", - icon = MifosIcons.History, - onClick = onNavigateToHistory, +@Composable +private fun QuickActionsSection( + onAddNewSchedule: () -> Unit, + onManageSchedules: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Quick Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + QuickActionButton( + text = "Add New", + icon = MifosIcons.Add, + onClick = onAddNewSchedule, + modifier = Modifier.weight(1f), + ) + + QuickActionButton( + text = "Manage", + icon = MifosIcons.Settings, + onClick = onManageSchedules, + modifier = Modifier.weight(1f), + ) + } } } } @Composable -private fun AutoPayOptionCard( - title: String, - description: String, +private fun QuickActionButton( + text: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text(text = text) + } +} + +@Composable +private fun ActiveScheduleCard( + schedule: AutoPaySchedule, + onClick: () -> Unit, + modifier: Modifier = Modifier, ) { Card( modifier = modifier.fillMaxWidth(), @@ -121,36 +364,251 @@ private fun AutoPayOptionCard( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = schedule.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = schedule.recipientName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + StatusChip(status = schedule.status) + } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + + Column { + Text( + text = "Next Payment", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = schedule.nextPaymentDate, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Column { + Text( + text = "Frequency", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = schedule.frequency, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } +} + +@Composable +private fun UpcomingPaymentCard( + payment: UpcomingPayment, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Column( modifier = Modifier.weight(1f), ) { Text( - text = title, + text = payment.scheduleName, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, ) Text( - text = description, + text = payment.recipientName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + StatusChip(status = payment.status) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.format(payment.amount, payment.currency, 2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + + Column { + Text( + text = "Due Date", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = payment.dueDate, + style = MaterialTheme.typography.bodyMedium, + ) + } } } } } + +@Composable +private fun StatusChip( + status: AutoPayStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + AutoPayStatus.ACTIVE -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + AutoPayStatus.PAUSED -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + AutoPayStatus.CANCELLED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + AutoPayStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun StatusChip( + status: PaymentStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + PaymentStatus.UPCOMING -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + PaymentStatus.PROCESSING -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + PaymentStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + PaymentStatus.FAILED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun EmptyStateCard( + title: String, + description: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt index d7ad18f56..b8205f5ac 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt @@ -11,44 +11,136 @@ package org.mifospay.feature.autopay import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +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.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons @Composable fun AutoPaySetupScreen( onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay Setup", + backPress = onNavigateBack, + ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(16.dp), ) { + Icon( + imageVector = MifosIcons.Settings, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( text = "AutoPay Setup", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, ) Text( - text = "Configure your automatic payment rules", + text = "Configure your automatic payment settings and preferences.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), ) + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxSize(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Setup Options", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "This screen will allow users to:", + style = MaterialTheme.typography.bodyMedium, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SetupOptionItem( + title = "Create New Schedule", + description = "Set up recurring payments with custom frequency and amounts", + ) + + SetupOptionItem( + title = "Link Bank Account", + description = "Connect your bank account for automatic transfers", + ) + + SetupOptionItem( + title = "Set Payment Limits", + description = "Configure maximum payment amounts and frequency limits", + ) + + SetupOptionItem( + title = "Choose Recipients", + description = "Add and manage payment recipients", + ) + } + } + } } } } + +@Composable +private fun SetupOptionItem( + title: String, + description: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(vertical = 8.dp), + ) { + Text( + text = "• $title", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt index 8f782e2e3..3f60c8ce5 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt @@ -9,14 +9,36 @@ */ package org.mifospay.feature.autopay +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized import org.mifospay.core.ui.utils.BaseViewModel -class AutoPayViewModel : BaseViewModel( - initialState = AutoPayState(), +class AutoPayViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AutoPayState(), ) { + companion object { + private const val KEY_STATE = "autopay_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadDashboardData() + } + override fun handleAction(action: AutoPayAction) { when (action) { is AutoPayAction.SetupRecurringPayment -> { @@ -34,33 +56,132 @@ class AutoPayViewModel : BaseViewModel { getPaymentHistory() } + is AutoPayAction.RefreshDashboard -> { + refreshDashboard() + } + is AutoPayAction.AddNewSchedule -> { + addNewSchedule() + } + is AutoPayAction.ManageExistingSchedules -> { + manageExistingSchedules() + } + is AutoPayAction.ViewScheduleDetails -> { + viewScheduleDetails(action.scheduleId) + } + } + } + + private fun loadDashboardData() { + mutableStateFlow.update { it.copy(isLoading = true) } + + // Simulate API call delay + viewModelScope.launch { + delay(1000) + + val dummySchedules = listOf( + AutoPaySchedule( + id = "1", + name = "Monthly Rent Payment", + amount = 1200.0, + currency = "USD", + frequency = "Monthly", + nextPaymentDate = "2024-02-15", + status = AutoPayStatus.ACTIVE, + recipientName = "Landlord Corp", + accountNumber = "****1234", + ), + AutoPaySchedule( + id = "2", + name = "Internet Bill", + amount = 89.99, + currency = "USD", + frequency = "Monthly", + nextPaymentDate = "2024-02-20", + status = AutoPayStatus.ACTIVE, + recipientName = "NetConnect", + accountNumber = "****5678", + ), + AutoPaySchedule( + id = "3", + name = "Gym Membership", + amount = 45.0, + currency = "USD", + frequency = "Monthly", + nextPaymentDate = "2024-02-25", + status = AutoPayStatus.PAUSED, + recipientName = "FitLife Gym", + accountNumber = "****9012", + ), + ) + + val dummyUpcomingPayments = listOf( + UpcomingPayment( + id = "1", + scheduleName = "Monthly Rent Payment", + amount = 1200.0, + currency = "USD", + dueDate = "2024-02-15", + status = PaymentStatus.UPCOMING, + recipientName = "Landlord Corp", + ), + UpcomingPayment( + id = "2", + scheduleName = "Internet Bill", + amount = 89.99, + currency = "USD", + dueDate = "2024-02-20", + status = PaymentStatus.UPCOMING, + recipientName = "NetConnect", + ), + ) + + mutableStateFlow.update { + it.copy( + isLoading = false, + activeSchedules = dummySchedules, + upcomingPayments = dummyUpcomingPayments, + totalActiveSchedules = dummySchedules.size, + totalUpcomingPayments = dummyUpcomingPayments.size, + ) + } } } + private fun refreshDashboard() { + loadDashboardData() + } + + private fun addNewSchedule() { + sendEvent(AutoPayEvent.NavigateToSetup) + } + + private fun manageExistingSchedules() { + sendEvent(AutoPayEvent.NavigateToRules) + } + + private fun viewScheduleDetails(scheduleId: String) { + sendEvent(AutoPayEvent.NavigateToScheduleDetails(scheduleId)) + } + private fun setupRecurringPayment() { - // TODO: Implement recurring payment setup logic sendEvent(AutoPayEvent.NavigateToSetup) } private fun configurePaymentRules() { - // TODO: Implement payment rules configuration sendEvent(AutoPayEvent.NavigateToRules) } private fun managePaymentPreferences() { - // TODO: Implement payment preferences management sendEvent(AutoPayEvent.NavigateToPreferences) } private fun toggleAutoPay(enabled: Boolean) { - // TODO: Implement auto-pay toggle logic mutableStateFlow.update { it.copy(isAutoPayEnabled = enabled) } } private fun getPaymentHistory() { - // TODO: Implement payment history retrieval sendEvent(AutoPayEvent.NavigateToHistory) } } @@ -70,13 +191,58 @@ data class AutoPayState( val isAutoPayEnabled: Boolean = false, val isLoading: Boolean = false, val error: String? = null, + val activeSchedules: List = emptyList(), + val upcomingPayments: List = emptyList(), + val totalActiveSchedules: Int = 0, + val totalUpcomingPayments: Int = 0, +) + +@Serializable +data class AutoPaySchedule( + val id: String, + val name: String, + val amount: Double, + val currency: String, + val frequency: String, + val nextPaymentDate: String, + val status: AutoPayStatus, + val recipientName: String, + val accountNumber: String, ) +@Serializable +data class UpcomingPayment( + val id: String, + val scheduleName: String, + val amount: Double, + val currency: String, + val dueDate: String, + val status: PaymentStatus, + val recipientName: String, +) + +@Serializable +enum class AutoPayStatus { + ACTIVE, + PAUSED, + CANCELLED, + COMPLETED, +} + +@Serializable +enum class PaymentStatus { + UPCOMING, + PROCESSING, + COMPLETED, + FAILED, +} + sealed interface AutoPayEvent { data object NavigateToSetup : AutoPayEvent data object NavigateToRules : AutoPayEvent data object NavigateToPreferences : AutoPayEvent data object NavigateToHistory : AutoPayEvent + data class NavigateToScheduleDetails(val scheduleId: String) : AutoPayEvent } sealed interface AutoPayAction { @@ -85,4 +251,8 @@ sealed interface AutoPayAction { data object ManagePaymentPreferences : AutoPayAction data object GetPaymentHistory : AutoPayAction data class ToggleAutoPay(val enabled: Boolean) : AutoPayAction + data object RefreshDashboard : AutoPayAction + data object AddNewSchedule : AutoPayAction + data object ManageExistingSchedules : AutoPayAction + data class ViewScheduleDetails(val scheduleId: String) : AutoPayAction } diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt index 34f148218..ec7d000b6 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt @@ -11,8 +11,10 @@ package org.mifospay.feature.autopay.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.autopay.AutoPayScheduleDetailsViewModel import org.mifospay.feature.autopay.AutoPayViewModel val AutoPayModule = module { viewModelOf(::AutoPayViewModel) + viewModelOf(::AutoPayScheduleDetailsViewModel) } From 49ba5ecd407c4fd3cf97dd69bac6d0abe0578c13 Mon Sep 17 00:00:00 2001 From: Biplab Dutta Date: Mon, 25 Aug 2025 15:05:29 +0530 Subject: [PATCH 10/10] feat(feature:autopay): implement data layer --- .../shared/navigation/MifosNavHost.kt | 1 + .../mifospay/core/data/di/RepositoryModule.kt | 3 + .../core/data/repository/AutoPayRepository.kt | 109 ++++++++++ .../repositoryImpl/AutoPayRepositoryImpl.kt | 188 ++++++++++++++++++ .../core/data/util/AutoPayErrorHandler.kt | 171 ++++++++++++++++ .../core/data/util/AutoPayValidator.kt | 151 ++++++++++++++ .../datastore/AutoPayPreferencesDataSource.kt | 171 ++++++++++++++++ .../datastore/AutoPayPreferencesRepository.kt | 84 ++++++++ .../AutoPayPreferencesRepositoryImpl.kt | 109 ++++++++++ .../core/datastore/di/PreferenceModule.kt | 12 ++ .../mifospay/core/model/autopay/AutoPay.kt | 113 +++++++++++ .../core/model/autopay/AutoPayPayload.kt | 94 +++++++++ .../core/network/FineractApiManager.kt | 2 + .../mifospay/core/network/KtorfitClient.kt | 3 + .../core/network/services/AutoPayService.kt | 136 +++++++++++++ .../core/network/utils/ApiEndPoints.kt | 3 + feature/autopay/README.md | 131 ------------ .../feature/autopay/AutoPayHistoryScreen.kt | 4 +- .../feature/autopay/AutoPayNavigation.kt | 36 ++-- .../autopay/AutoPayPreferencesScreen.kt | 4 +- .../feature/autopay/AutoPayRulesScreen.kt | 6 +- .../autopay/AutoPayScheduleDetailsScreen.kt | 12 +- .../mifospay/feature/autopay/AutoPayScreen.kt | 33 ++- .../feature/autopay/AutoPaySetupScreen.kt | 4 +- 24 files changed, 1398 insertions(+), 182 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt create mode 100644 core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt create mode 100644 core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt create mode 100644 core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt create mode 100644 core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt create mode 100644 core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt create mode 100644 core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt create mode 100644 core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt create mode 100644 core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt 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 d5fec4417..0778b554d 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 @@ -152,6 +152,7 @@ internal fun MifosNavHost( onNavigateToScheduleDetails = { scheduleId -> navController.navigateToAutoPayScheduleDetails(scheduleId) }, + showTopBar = false, ) }, ) diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt index 053c48b7b..0ef5e4d1b 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt @@ -16,6 +16,7 @@ import org.mifospay.core.common.MifosDispatchers import org.mifospay.core.data.repository.AccountRepository import org.mifospay.core.data.repository.AssetRepository import org.mifospay.core.data.repository.AuthenticationRepository +import org.mifospay.core.data.repository.AutoPayRepository import org.mifospay.core.data.repository.BeneficiaryRepository import org.mifospay.core.data.repository.ClientRepository import org.mifospay.core.data.repository.DocumentRepository @@ -36,6 +37,7 @@ import org.mifospay.core.data.repository.UserRepository import org.mifospay.core.data.repositoryImpl.AccountRepositoryImpl import org.mifospay.core.data.repositoryImpl.AssetRepositoryImpl import org.mifospay.core.data.repositoryImpl.AuthenticationRepositoryImpl +import org.mifospay.core.data.repositoryImpl.AutoPayRepositoryImpl import org.mifospay.core.data.repositoryImpl.BeneficiaryRepositoryImpl import org.mifospay.core.data.repositoryImpl.ClientRepositoryImpl import org.mifospay.core.data.repositoryImpl.DocumentRepositoryImpl @@ -93,6 +95,7 @@ val RepositoryModule = module { } single { TwoFactorAuthRepositoryImpl(get(), get(ioDispatcher)) } single { UserRepositoryImpl(get(), get(ioDispatcher)) } + single { AutoPayRepositoryImpl(get(), get(ioDispatcher)) } includes(platformModule) single { getPlatformDataModule } diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt new file mode 100644 index 000000000..91806a986 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt @@ -0,0 +1,109 @@ +/* + * 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.repository + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayTemplate +import org.mifospay.core.model.autopay.AutoPayUpdatePayload +import org.mifospay.core.model.autopay.UpcomingPayment +import org.mifospay.core.network.model.entity.Page + +interface AutoPayRepository { + /** + * Get AutoPay template for creating new AutoPay schedules + */ + fun getAutoPayTemplate( + clientId: Long, + sourceAccountId: Long, + ): Flow> + + /** + * Get all AutoPay schedules for a client + */ + fun getAllAutoPaySchedules( + clientId: Long, + ): Flow>> + + /** + * Get AutoPay schedule by ID + */ + fun getAutoPaySchedule(autoPayId: Long): Flow> + + /** + * Create a new AutoPay schedule + */ + suspend fun createAutoPaySchedule( + payload: AutoPayPayload, + ): DataState + + /** + * Update an existing AutoPay schedule + */ + suspend fun updateAutoPaySchedule( + autoPayId: Long, + payload: AutoPayUpdatePayload, + ): DataState + + /** + * Delete an AutoPay schedule + */ + suspend fun deleteAutoPaySchedule(autoPayId: Long): DataState + + /** + * Pause an AutoPay schedule + */ + suspend fun pauseAutoPaySchedule(autoPayId: Long): DataState + + /** + * Resume a paused AutoPay schedule + */ + suspend fun resumeAutoPaySchedule(autoPayId: Long): DataState + + /** + * Get AutoPay payment history + */ + fun getAutoPayHistory( + autoPayId: Long, + limit: Int = 20, + ): Flow>> + + /** + * Get upcoming payments for all AutoPay schedules + */ + fun getUpcomingPayments( + clientId: Long, + limit: Int = 10, + ): Flow>> + + /** + * Get AutoPay statistics for dashboard + */ + fun getAutoPayStatistics( + clientId: Long, + ): Flow> + + /** + * Validate AutoPay payload before submission + */ + suspend fun validateAutoPayPayload(payload: AutoPayPayload): DataState +} + +data class AutoPayStatistics( + val totalActiveSchedules: Int = 0, + val totalPausedSchedules: Int = 0, + val totalCompletedSchedules: Int = 0, + val totalUpcomingPayments: Int = 0, + val totalAmountThisMonth: Double = 0.0, + val currency: String = "USD", +) diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt new file mode 100644 index 000000000..2196bbe57 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt @@ -0,0 +1,188 @@ +/* + * 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.repositoryImpl + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.mifospay.core.common.DataState +import org.mifospay.core.common.asDataStateFlow +import org.mifospay.core.data.repository.AutoPayRepository +import org.mifospay.core.data.repository.AutoPayStatistics +import org.mifospay.core.data.util.AutoPayValidator +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayTemplate +import org.mifospay.core.model.autopay.AutoPayUpdatePayload +import org.mifospay.core.model.autopay.UpcomingPayment +import org.mifospay.core.network.FineractApiManager + +class AutoPayRepositoryImpl( + private val apiManager: FineractApiManager, + private val ioDispatcher: CoroutineDispatcher, +) : AutoPayRepository { + + override fun getAutoPayTemplate( + clientId: Long, + sourceAccountId: Long, + ): Flow> { + return apiManager.autoPayApi + .getAutoPayTemplate(clientId, sourceAccountId) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getAllAutoPaySchedules( + clientId: Long, + ): Flow>> { + return apiManager.autoPayApi + .getAllAutoPaySchedules(clientId) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getAutoPaySchedule( + autoPayId: Long, + ): Flow> { + return apiManager.autoPayApi + .getAutoPaySchedule(autoPayId) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override suspend fun createAutoPaySchedule( + payload: AutoPayPayload, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.createAutoPaySchedule(payload) + } + DataState.Success("AutoPay schedule created successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun updateAutoPaySchedule( + autoPayId: Long, + payload: AutoPayUpdatePayload, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.updateAutoPaySchedule( + autoPayId = autoPayId, + payload = payload, + ) + } + DataState.Success("AutoPay schedule updated successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun deleteAutoPaySchedule( + autoPayId: Long, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.deleteAutoPaySchedule(autoPayId) + } + DataState.Success("AutoPay schedule deleted successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun pauseAutoPaySchedule( + autoPayId: Long, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.pauseAutoPaySchedule(autoPayId) + } + DataState.Success("AutoPay schedule paused successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun resumeAutoPaySchedule( + autoPayId: Long, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.resumeAutoPaySchedule(autoPayId) + } + DataState.Success("AutoPay schedule resumed successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override fun getAutoPayHistory( + autoPayId: Long, + limit: Int, + ): Flow>> { + return apiManager.autoPayApi + .getAutoPayHistory(autoPayId, limit) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getUpcomingPayments( + clientId: Long, + limit: Int, + ): Flow>> { + return apiManager.autoPayApi + .getUpcomingPayments(clientId, limit) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getAutoPayStatistics( + clientId: Long, + ): Flow> { + return apiManager.autoPayApi + .getAutoPayStatistics(clientId) + .catch { DataState.Error(it, null) } + .map { response -> + AutoPayStatistics( + totalActiveSchedules = response.totalActiveSchedules, + totalPausedSchedules = response.totalPausedSchedules, + totalCompletedSchedules = response.totalCompletedSchedules, + totalUpcomingPayments = response.totalUpcomingPayments, + totalAmountThisMonth = response.totalAmountThisMonth, + currency = response.currency, + ) + } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override suspend fun validateAutoPayPayload( + payload: AutoPayPayload, + ): DataState { + return try { + withContext(ioDispatcher) { + when (val validationResult = AutoPayValidator.validateAutoPayPayload(payload)) { + is AutoPayValidator.ValidationResult.Valid -> DataState.Success(true) + is AutoPayValidator.ValidationResult.Invalid -> { + DataState.Error(Exception(validationResult.errorMessage), null) + } + } + } + } catch (e: Exception) { + DataState.Error(e, null) + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt new file mode 100644 index 000000000..cc2ada913 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt @@ -0,0 +1,171 @@ +/* + * 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 io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.http.HttpStatusCode +import org.mifospay.core.common.DataState + +object AutoPayErrorHandler { + + sealed class AutoPayError( + open val message: String, + open val code: String? = null, + ) { + data class NetworkError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class ValidationError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class ServerError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class AuthenticationError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class AuthorizationError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class NotFoundError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class ConflictError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class RateLimitError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class UnknownError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + } + + fun handleException(exception: Exception): AutoPayError { + return when (exception) { + is ClientRequestException -> handleClientRequestException(exception) + is ServerResponseException -> handleServerResponseException(exception) + is IllegalArgumentException -> AutoPayError.ValidationError( + message = exception.message ?: "Invalid input provided", + code = "VALIDATION_ERROR", + ) + is IllegalStateException -> AutoPayError.ValidationError( + message = exception.message ?: "Invalid state", + code = "STATE_ERROR", + ) + else -> AutoPayError.UnknownError( + message = exception.message ?: "An unexpected error occurred", + code = "UNKNOWN_ERROR", + ) + } + } + + private fun handleClientRequestException(exception: ClientRequestException): AutoPayError { + return when (exception.response.status) { + HttpStatusCode.Unauthorized -> AutoPayError.AuthenticationError( + message = "Authentication required. Please log in again.", + code = "UNAUTHORIZED", + ) + HttpStatusCode.Forbidden -> AutoPayError.AuthorizationError( + message = "You don't have permission to perform this action.", + code = "FORBIDDEN", + ) + HttpStatusCode.NotFound -> AutoPayError.NotFoundError( + message = "The requested AutoPay schedule was not found.", + code = "NOT_FOUND", + ) + HttpStatusCode.Conflict -> AutoPayError.ConflictError( + message = "The AutoPay schedule already exists or conflicts with existing data.", + code = "CONFLICT", + ) + HttpStatusCode.TooManyRequests -> AutoPayError.RateLimitError( + message = "Too many requests. Please try again later.", + code = "RATE_LIMIT", + ) + HttpStatusCode.BadRequest -> AutoPayError.ValidationError( + message = "Invalid request data. Please check your input.", + code = "BAD_REQUEST", + ) + else -> AutoPayError.NetworkError( + message = "Network error occurred. Please check your connection.", + code = "NETWORK_ERROR", + ) + } + } + + private fun handleServerResponseException(exception: ServerResponseException): AutoPayError { + return when (exception.response.status) { + HttpStatusCode.InternalServerError -> AutoPayError.ServerError( + message = "Server error occurred. Please try again later.", + code = "INTERNAL_SERVER_ERROR", + ) + HttpStatusCode.ServiceUnavailable -> AutoPayError.ServerError( + message = "Service temporarily unavailable. Please try again later.", + code = "SERVICE_UNAVAILABLE", + ) + HttpStatusCode.GatewayTimeout -> AutoPayError.NetworkError( + message = "Request timeout. Please try again.", + code = "TIMEOUT", + ) + else -> AutoPayError.ServerError( + message = "Server error occurred. Please try again later.", + code = "SERVER_ERROR", + ) + } + } + + fun createErrorDataState(error: AutoPayError): DataState { + return DataState.Error( + exception = Exception(error.message), + data = null, + ) + } + + fun getErrorMessage(error: AutoPayError): String { + return when (error) { + is AutoPayError.NetworkError -> "Network Error: ${error.message}" + is AutoPayError.ValidationError -> "Validation Error: ${error.message}" + is AutoPayError.ServerError -> "Server Error: ${error.message}" + is AutoPayError.AuthenticationError -> "Authentication Error: ${error.message}" + is AutoPayError.AuthorizationError -> "Authorization Error: ${error.message}" + is AutoPayError.NotFoundError -> "Not Found: ${error.message}" + is AutoPayError.ConflictError -> "Conflict: ${error.message}" + is AutoPayError.RateLimitError -> "Rate Limit: ${error.message}" + is AutoPayError.UnknownError -> "Error: ${error.message}" + } + } + + fun isRetryableError(error: AutoPayError): Boolean { + return when (error) { + is AutoPayError.NetworkError -> true + is AutoPayError.ServerError -> true + is AutoPayError.RateLimitError -> true + else -> false + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt new file mode 100644 index 000000000..b9d01e8e0 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt @@ -0,0 +1,151 @@ +/* + * 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 kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayUpdatePayload + +@OptIn(FormatStringsInDatetimeFormats::class) +object AutoPayValidator { + + private val dateFormat = LocalDateTime.Format { + byUnicodePattern("dd MMMM yyyy") + } + + sealed class ValidationResult { + data object Valid : ValidationResult() + data class Invalid(val errorMessage: String) : ValidationResult() + } + + fun validateAutoPayPayload(payload: AutoPayPayload): ValidationResult { + return when { + payload.name.isBlank() -> ValidationResult.Invalid("Schedule name is required") + payload.name.length < 3 -> ValidationResult.Invalid("Schedule name must be at least 3 characters") + payload.name.length > 100 -> ValidationResult.Invalid("Schedule name must be less than 100 characters") + + payload.amount.isBlank() -> ValidationResult.Invalid("Amount is required") + !isValidAmount(payload.amount) -> ValidationResult.Invalid("Invalid amount format") + payload.amount.toDoubleOrNull()?.let { it <= 0 } == true -> ValidationResult.Invalid("Amount must be greater than 0") + + payload.currency.isBlank() -> ValidationResult.Invalid("Currency is required") + !isValidCurrency(payload.currency) -> ValidationResult.Invalid("Invalid currency code") + + payload.frequency.isBlank() -> ValidationResult.Invalid("Frequency is required") + !isValidFrequency(payload.frequency) -> ValidationResult.Invalid("Invalid frequency") + + payload.recipientName.isBlank() -> ValidationResult.Invalid("Recipient name is required") + payload.recipientName.length < 2 -> ValidationResult.Invalid("Recipient name must be at least 2 characters") + payload.recipientName.length > 100 -> ValidationResult.Invalid("Recipient name must be less than 100 characters") + + payload.recipientAccountNumber.isBlank() -> ValidationResult.Invalid("Recipient account number is required") + !isValidAccountNumber(payload.recipientAccountNumber) -> ValidationResult.Invalid("Invalid account number format") + + payload.sourceAccountId <= 0 -> ValidationResult.Invalid("Invalid source account") + payload.clientId <= 0 -> ValidationResult.Invalid("Invalid client ID") + + payload.validFrom.isNotBlank() && !isValidDate(payload.validFrom) -> ValidationResult.Invalid("Invalid start date format") + payload.validTill.isNotBlank() && !isValidDate(payload.validTill) -> ValidationResult.Invalid("Invalid end date format") + + payload.validFrom.isNotBlank() && payload.validTill.isNotBlank() -> { + val startDate = parseDate(payload.validFrom) + val endDate = parseDate(payload.validTill) + if (startDate != null && endDate != null && startDate >= endDate) { + ValidationResult.Invalid("End date must be after start date") + } else { + ValidationResult.Valid + } + } + + else -> ValidationResult.Valid + } + } + + fun validateAutoPayUpdatePayload(payload: AutoPayUpdatePayload): ValidationResult { + return when { + payload.name?.let { it.isBlank() } == true -> ValidationResult.Invalid("Schedule name cannot be empty") + payload.name?.let { it.length < 3 } == true -> ValidationResult.Invalid("Schedule name must be at least 3 characters") + payload.name?.let { it.length > 100 } == true -> ValidationResult.Invalid("Schedule name must be less than 100 characters") + + payload.amount?.let { it.isBlank() } == true -> ValidationResult.Invalid("Amount cannot be empty") + payload.amount?.let { !isValidAmount(it) } == true -> ValidationResult.Invalid("Invalid amount format") + payload.amount?.toDoubleOrNull()?.let { it <= 0 } == true -> ValidationResult.Invalid("Amount must be greater than 0") + + payload.currency?.let { it.isBlank() } == true -> ValidationResult.Invalid("Currency cannot be empty") + payload.currency?.let { !isValidCurrency(it) } == true -> ValidationResult.Invalid("Invalid currency code") + + payload.frequency?.let { it.isBlank() } == true -> ValidationResult.Invalid("Frequency cannot be empty") + payload.frequency?.let { !isValidFrequency(it) } == true -> ValidationResult.Invalid("Invalid frequency") + + payload.recipientName?.let { it.isBlank() } == true -> ValidationResult.Invalid("Recipient name cannot be empty") + payload.recipientName?.let { it.length < 2 } == true -> ValidationResult.Invalid("Recipient name must be at least 2 characters") + payload.recipientName?.let { it.length > 100 } == true -> ValidationResult.Invalid("Recipient name must be less than 100 characters") + + payload.recipientAccountNumber?.let { it.isBlank() } == true -> ValidationResult.Invalid("Recipient account number cannot be empty") + payload.recipientAccountNumber?.let { !isValidAccountNumber(it) } == true -> ValidationResult.Invalid("Invalid account number format") + + payload.validFrom?.let { it.isNotBlank() && !isValidDate(it) } == true -> ValidationResult.Invalid("Invalid start date format") + payload.validTill?.let { it.isNotBlank() && !isValidDate(it) } == true -> ValidationResult.Invalid("Invalid end date format") + + payload.validFrom?.let { it.isNotBlank() } == true && payload.validTill?.let { it.isNotBlank() } == true -> { + val startDate = parseDate(payload.validFrom!!) + val endDate = parseDate(payload.validTill!!) + if (startDate != null && endDate != null && startDate >= endDate) { + ValidationResult.Invalid("End date must be after start date") + } else { + ValidationResult.Valid + } + } + + else -> ValidationResult.Valid + } + } + + private fun isValidAmount(amount: String): Boolean { + return try { + amount.toDoubleOrNull() != null && amount.toDouble() > 0 + } catch (e: NumberFormatException) { + false + } + } + + private fun isValidCurrency(currency: String): Boolean { + return currency.length == 3 && currency.all { it.isLetter() } + } + + private fun isValidFrequency(frequency: String): Boolean { + val validFrequencies = listOf("DAILY", "WEEKLY", "MONTHLY", "QUARTERLY", "YEARLY") + return validFrequencies.contains(frequency.uppercase()) + } + + private fun isValidAccountNumber(accountNumber: String): Boolean { + return accountNumber.length >= 8 && accountNumber.length <= 20 && accountNumber.all { it.isLetterOrDigit() } + } + + private fun isValidDate(date: String): Boolean { + return try { + dateFormat.parse(date) + true + } catch (e: Exception) { + false + } + } + + private fun parseDate(date: String): LocalDate? { + return try { + dateFormat.parse(date).date + } catch (e: Exception) { + null + } + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt new file mode 100644 index 000000000..d550535ca --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt @@ -0,0 +1,171 @@ +/* + * 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 + */ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) + +package org.mifospay.core.datastore + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.Settings +import com.russhwolf.settings.serialization.decodeValue +import com.russhwolf.settings.serialization.encodeValue +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.ListSerializer +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.UpcomingPayment + +private const val IS_AUTO_PAY_ENABLED_KEY = "is_autopay_enabled" +private const val CACHED_AUTO_PAY_SCHEDULES_KEY = "cached_autopay_schedules" +private const val CACHED_UPCOMING_PAYMENTS_KEY = "cached_upcoming_payments" +private const val CACHED_AUTO_PAY_HISTORY_KEY = "cached_autopay_history" +private const val LAST_SYNC_TIMESTAMP_KEY = "last_sync_timestamp" + +@OptIn(ExperimentalSerializationApi::class) +class AutoPayPreferencesDataSource( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, +) { + + private val _isAutoPayEnabled = MutableStateFlow( + settings.getBoolean(IS_AUTO_PAY_ENABLED_KEY, false), + ) + + private val _cachedAutoPaySchedules = MutableStateFlow( + settings.decodeValue( + key = CACHED_AUTO_PAY_SCHEDULES_KEY, + serializer = ListSerializer(AutoPay.serializer()), + defaultValue = emptyList(), + ), + ) + + private val _cachedUpcomingPayments = MutableStateFlow( + settings.decodeValue( + key = CACHED_UPCOMING_PAYMENTS_KEY, + serializer = ListSerializer(UpcomingPayment.serializer()), + defaultValue = emptyList(), + ), + ) + + private val _cachedAutoPayHistory = MutableStateFlow( + settings.decodeValue( + key = CACHED_AUTO_PAY_HISTORY_KEY, + serializer = ListSerializer(AutoPayHistory.serializer()), + defaultValue = emptyList(), + ), + ) + + private val _lastSyncTimestamp = MutableStateFlow( + settings.getLong(LAST_SYNC_TIMESTAMP_KEY, 0L), + ) + + val isAutoPayEnabled: StateFlow = _isAutoPayEnabled + val cachedAutoPaySchedules: Flow> = _cachedAutoPaySchedules + val cachedUpcomingPayments: Flow> = _cachedUpcomingPayments + val cachedAutoPayHistory: Flow> = _cachedAutoPayHistory + val lastSyncTimestamp: StateFlow = _lastSyncTimestamp + + suspend fun updateAutoPayEnabled(enabled: Boolean) { + withContext(dispatcher) { + settings.putBoolean(IS_AUTO_PAY_ENABLED_KEY, enabled) + _isAutoPayEnabled.value = enabled + } + } + + suspend fun cacheAutoPaySchedules(schedules: List) { + withContext(dispatcher) { + settings.putAutoPaySchedules(schedules) + _cachedAutoPaySchedules.value = schedules + } + } + + suspend fun cacheUpcomingPayments(payments: List) { + withContext(dispatcher) { + settings.putUpcomingPayments(payments) + _cachedUpcomingPayments.value = payments + } + } + + suspend fun cacheAutoPayHistory(history: List) { + withContext(dispatcher) { + settings.putAutoPayHistory(history) + _cachedAutoPayHistory.value = history + } + } + + suspend fun updateLastSyncTimestamp(timestamp: Long) { + withContext(dispatcher) { + settings.putLong(LAST_SYNC_TIMESTAMP_KEY, timestamp) + _lastSyncTimestamp.value = timestamp + } + } + + suspend fun updateLastSyncTimestamp() { + withContext(dispatcher) { + val timestamp = Clock.System.now().toEpochMilliseconds() + settings.putLong(LAST_SYNC_TIMESTAMP_KEY, timestamp) + _lastSyncTimestamp.value = timestamp + } + } + + suspend fun clearCache() { + withContext(dispatcher) { + settings.remove(CACHED_AUTO_PAY_SCHEDULES_KEY) + settings.remove(CACHED_UPCOMING_PAYMENTS_KEY) + settings.remove(CACHED_AUTO_PAY_HISTORY_KEY) + settings.remove(LAST_SYNC_TIMESTAMP_KEY) + + _cachedAutoPaySchedules.value = emptyList() + _cachedUpcomingPayments.value = emptyList() + _cachedAutoPayHistory.value = emptyList() + _lastSyncTimestamp.value = 0L + } + } + + suspend fun getCachedAutoPaySchedule(autoPayId: Long): AutoPay? { + return _cachedAutoPaySchedules.value.find { autoPay -> autoPay.id == autoPayId } + } + + suspend fun isCacheStale(maxAgeMinutes: Long): Boolean { + val lastSync = _lastSyncTimestamp.value + val currentTime = Clock.System.now().toEpochMilliseconds() + val maxAgeMillis = maxAgeMinutes * 60 * 1000 + return (currentTime - lastSync) > maxAgeMillis + } +} + +private fun Settings.putAutoPaySchedules(schedules: List) { + encodeValue( + key = CACHED_AUTO_PAY_SCHEDULES_KEY, + serializer = ListSerializer(AutoPay.serializer()), + value = schedules, + ) +} + +private fun Settings.putUpcomingPayments(payments: List) { + encodeValue( + key = CACHED_UPCOMING_PAYMENTS_KEY, + serializer = ListSerializer(UpcomingPayment.serializer()), + value = payments, + ) +} + +private fun Settings.putAutoPayHistory(history: List) { + encodeValue( + key = CACHED_AUTO_PAY_HISTORY_KEY, + serializer = ListSerializer(AutoPayHistory.serializer()), + value = history, + ) +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt new file mode 100644 index 000000000..40d3f6c73 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt @@ -0,0 +1,84 @@ +/* + * 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.datastore + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.UpcomingPayment + +interface AutoPayPreferencesRepository { + /** + * AutoPay enabled state + */ + val isAutoPayEnabled: StateFlow + + /** + * Cached AutoPay schedules + */ + val cachedAutoPaySchedules: Flow> + + /** + * Cached upcoming payments + */ + val cachedUpcomingPayments: Flow> + + /** + * Cached AutoPay history + */ + val cachedAutoPayHistory: Flow> + + /** + * Last sync timestamp + */ + val lastSyncTimestamp: StateFlow + + /** + * Update AutoPay enabled state + */ + suspend fun updateAutoPayEnabled(enabled: Boolean): DataState + + /** + * Cache AutoPay schedules + */ + suspend fun cacheAutoPaySchedules(schedules: List): DataState + + /** + * Cache upcoming payments + */ + suspend fun cacheUpcomingPayments(payments: List): DataState + + /** + * Cache AutoPay history + */ + suspend fun cacheAutoPayHistory(history: List): DataState + + /** + * Update last sync timestamp + */ + suspend fun updateLastSyncTimestamp(timestamp: Long): DataState + + /** + * Clear all cached data + */ + suspend fun clearCache(): DataState + + /** + * Get cached AutoPay schedule by ID + */ + suspend fun getCachedAutoPaySchedule(autoPayId: Long): AutoPay? + + /** + * Check if cache is stale (older than specified time) + */ + suspend fun isCacheStale(maxAgeMinutes: Long = 30): Boolean +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt new file mode 100644 index 000000000..63562dac3 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt @@ -0,0 +1,109 @@ +/* + * 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.datastore + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.UpcomingPayment + +class AutoPayPreferencesRepositoryImpl( + private val autoPayPreferencesDataSource: AutoPayPreferencesDataSource, + private val ioDispatcher: CoroutineDispatcher, + unconfinedDispatcher: CoroutineDispatcher, +) : AutoPayPreferencesRepository { + private val unconfinedScope = CoroutineScope(unconfinedDispatcher) + + override val isAutoPayEnabled: StateFlow = autoPayPreferencesDataSource.isAutoPayEnabled + + override val cachedAutoPaySchedules: Flow> = autoPayPreferencesDataSource.cachedAutoPaySchedules.flowOn(ioDispatcher) + + override val cachedUpcomingPayments: Flow> = autoPayPreferencesDataSource.cachedUpcomingPayments.flowOn(ioDispatcher) + + override val cachedAutoPayHistory: Flow> = autoPayPreferencesDataSource.cachedAutoPayHistory.flowOn(ioDispatcher) + + override val lastSyncTimestamp: StateFlow = autoPayPreferencesDataSource.lastSyncTimestamp + + override suspend fun updateAutoPayEnabled(enabled: Boolean): DataState { + return try { + autoPayPreferencesDataSource.updateAutoPayEnabled(enabled) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun cacheAutoPaySchedules(schedules: List): DataState { + return try { + autoPayPreferencesDataSource.cacheAutoPaySchedules(schedules) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun cacheUpcomingPayments(payments: List): DataState { + return try { + autoPayPreferencesDataSource.cacheUpcomingPayments(payments) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun cacheAutoPayHistory(history: List): DataState { + return try { + autoPayPreferencesDataSource.cacheAutoPayHistory(history) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun updateLastSyncTimestamp(timestamp: Long): DataState { + return try { + autoPayPreferencesDataSource.updateLastSyncTimestamp(timestamp) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + suspend fun updateLastSyncTimestamp(): DataState { + return try { + autoPayPreferencesDataSource.updateLastSyncTimestamp() + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun clearCache(): DataState { + return try { + autoPayPreferencesDataSource.clearCache() + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun getCachedAutoPaySchedule(autoPayId: Long): AutoPay? { + return autoPayPreferencesDataSource.getCachedAutoPaySchedule(autoPayId) + } + + override suspend fun isCacheStale(maxAgeMinutes: Long): Boolean { + return autoPayPreferencesDataSource.isCacheStale(maxAgeMinutes) + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt index e74bee4cf..6b0549654 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt @@ -13,6 +13,9 @@ import com.russhwolf.settings.Settings import org.koin.core.qualifier.named import org.koin.dsl.module import org.mifospay.core.common.MifosDispatchers +import org.mifospay.core.datastore.AutoPayPreferencesDataSource +import org.mifospay.core.datastore.AutoPayPreferencesRepository +import org.mifospay.core.datastore.AutoPayPreferencesRepositoryImpl import org.mifospay.core.datastore.UserPreferencesDataSource import org.mifospay.core.datastore.UserPreferencesRepository import org.mifospay.core.datastore.UserPreferencesRepositoryImpl @@ -21,6 +24,7 @@ val PreferencesModule = module { factory { Settings() } // Use the IO dispatcher name - MifosDispatchers.IO.name factory { UserPreferencesDataSource(get(), get(named(MifosDispatchers.IO.name))) } + factory { AutoPayPreferencesDataSource(get(), get(named(MifosDispatchers.IO.name))) } single { UserPreferencesRepositoryImpl( @@ -29,4 +33,12 @@ val PreferencesModule = module { unconfinedDispatcher = get(named(MifosDispatchers.Unconfined.name)), ) } + + single { + AutoPayPreferencesRepositoryImpl( + autoPayPreferencesDataSource = get(), + ioDispatcher = get(named(MifosDispatchers.IO.name)), + unconfinedDispatcher = get(named(MifosDispatchers.Unconfined.name)), + ) + } } diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt new file mode 100644 index 000000000..a08c19367 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt @@ -0,0 +1,113 @@ +/* + * 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.autopay + +import kotlinx.serialization.Serializable +import org.mifospay.core.common.Parcelable +import org.mifospay.core.common.Parcelize + +// TODO: Align data models with final API response schema once confirmed by backend + +@Serializable +@Parcelize +data class AutoPay( + val id: Long? = null, + val name: String? = null, + val description: String? = null, + val amount: Double? = null, + val currency: String? = null, + val frequency: String? = null, + val frequencyInterval: Int? = null, + val nextPaymentDate: String? = null, + val status: AutoPayStatus? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val recipientBankCode: String? = null, + val sourceAccountId: Long? = null, + val sourceAccountNumber: String? = null, + val sourceAccountType: String? = null, + val clientId: Long? = null, + val createdDate: String? = null, + val lastModifiedDate: String? = null, + val validFrom: String? = null, + val validTill: String? = null, + val maxAmount: Double? = null, + val minAmount: Double? = null, + val paymentMethod: String? = null, + val isActive: Boolean? = null, +) : Parcelable + +@Serializable +@Parcelize +data class AutoPayTemplate( + val id: Long? = null, + val name: String? = null, + val description: String? = null, + val frequencyOptions: List? = emptyList(), + val paymentMethods: List? = emptyList(), + val currencyOptions: List? = emptyList(), + val accountTypes: List? = emptyList(), + val maxAmount: Double? = null, + val minAmount: Double? = null, +) : Parcelable + +@Serializable +@Parcelize +data class FrequencyOption( + val id: Long, + val code: String, + val value: String, + val description: String? = null, +) : Parcelable + +@Serializable +@Parcelize +data class PaymentMethod( + val id: Long, + val code: String, + val value: String, + val description: String? = null, +) : Parcelable + +@Serializable +@Parcelize +data class CurrencyOption( + val code: String, + val name: String, + val symbol: String? = null, +) : Parcelable + +@Serializable +@Parcelize +data class AccountType( + val id: Long, + val code: String, + val value: String, + val description: String? = null, +) : Parcelable + +@Serializable +enum class AutoPayStatus { + ACTIVE, + PAUSED, + CANCELLED, + COMPLETED, + FAILED, + PENDING, +} + +@Serializable +enum class PaymentStatus { + UPCOMING, + PROCESSING, + COMPLETED, + FAILED, + CANCELLED, +} diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt new file mode 100644 index 000000000..402283f2e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt @@ -0,0 +1,94 @@ +/* + * 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.autopay + +import kotlinx.serialization.Serializable +import org.mifospay.core.common.Parcelable +import org.mifospay.core.common.Parcelize + +// TODO: Align data models with final API response schema once confirmed by backend +@Serializable +@Parcelize +data class AutoPayPayload( + val name: String = "", + val description: String = "", + val amount: String = "", + val currency: String = "", + val frequency: String = "", + val frequencyInterval: String = "", + val recipientName: String = "", + val recipientAccountNumber: String = "", + val recipientBankCode: String = "", + val sourceAccountId: Long = 0, + val sourceAccountNumber: String = "", + val sourceAccountType: String = "", + val clientId: Long = 0, + val validFrom: String = "", + val validTill: String = "", + val maxAmount: String = "", + val minAmount: String = "", + val paymentMethod: String = "", + val locale: String = "en", + val dateFormat: String = "dd MMMM yyyy", +) : Parcelable + +@Serializable +@Parcelize +data class AutoPayUpdatePayload( + val name: String? = null, + val description: String? = null, + val amount: String? = null, + val currency: String? = null, + val frequency: String? = null, + val frequencyInterval: String? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val recipientBankCode: String? = null, + val validFrom: String? = null, + val validTill: String? = null, + val maxAmount: String? = null, + val minAmount: String? = null, + val paymentMethod: String? = null, + val status: String? = null, + val locale: String = "en", + val dateFormat: String = "dd MMMM yyyy", +) : Parcelable + +@Serializable +@Parcelize +data class AutoPayHistory( + val id: Long? = null, + val autoPayId: Long? = null, + val amount: Double? = null, + val currency: String? = null, + val status: PaymentStatus? = null, + val transactionDate: String? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val sourceAccountNumber: String? = null, + val referenceNumber: String? = null, + val failureReason: String? = null, + val createdDate: String? = null, +) : Parcelable + +@Serializable +@Parcelize +data class UpcomingPayment( + val id: String? = null, + val autoPayId: Long? = null, + val scheduleName: String? = null, + val amount: Double? = null, + val currency: String? = null, + val dueDate: String? = null, + val status: PaymentStatus? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val sourceAccountNumber: String? = null, +) : Parcelable diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt index 388b1df68..2fd8e81be 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt @@ -44,4 +44,6 @@ class FineractApiManager( val savingsAccountsApi by lazy { ktorfitClient.savingsAccountsApi } val standingInstructionApi by lazy { ktorfitClient.standingInstructionApi } + + val autoPayApi by lazy { ktorfitClient.autoPayApi } } diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt index 66f05fc41..9a7a0b19e 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt @@ -12,6 +12,7 @@ package org.mifospay.core.network import de.jensklingenberg.ktorfit.Ktorfit import org.mifospay.core.network.services.createAccountTransfersService import org.mifospay.core.network.services.createAuthenticationService +import org.mifospay.core.network.services.createAutoPayService import org.mifospay.core.network.services.createBeneficiaryService import org.mifospay.core.network.services.createClientService import org.mifospay.core.network.services.createDocumentService @@ -63,5 +64,7 @@ class KtorfitClient( internal val standingInstructionApi by lazy { ktorfit.createStandingInstructionService() } + internal val autoPayApi by lazy { ktorfit.createAutoPayService() } + internal val beneficiaryApi by lazy { ktorfit.createBeneficiaryService() } } diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt new file mode 100644 index 000000000..bee42d886 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt @@ -0,0 +1,136 @@ +/* + * 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.network.services + +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayTemplate +import org.mifospay.core.model.autopay.AutoPayUpdatePayload +import org.mifospay.core.model.autopay.UpcomingPayment +import org.mifospay.core.network.model.entity.Page +import org.mifospay.core.network.utils.ApiEndPoints + +// TODO: Sync with backend team and update service layer according to finalized API contract + +interface AutoPayService { + + /** + * Get AutoPay template for creating new AutoPay schedules + */ + @GET("${ApiEndPoints.AUTO_PAY}/template") + fun getAutoPayTemplate( + @Query("clientId") clientId: Long, + @Query("sourceAccountId") sourceAccountId: Long, + ): Flow + + /** + * Get all AutoPay schedules for a client + */ + @GET(ApiEndPoints.AUTO_PAY) + fun getAllAutoPaySchedules( + @Query("clientId") clientId: Long, + ): Flow> + + /** + * Get AutoPay schedule by ID + */ + @GET("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + fun getAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + ): Flow + + /** + * Create a new AutoPay schedule + */ + @POST(ApiEndPoints.AUTO_PAY) + suspend fun createAutoPaySchedule( + @Body payload: AutoPayPayload, + ) + + /** + * Update an existing AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun updateAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Body payload: AutoPayUpdatePayload, + @Query("command") command: String = "update", + ) + + /** + * Delete an AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun deleteAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Query("command") command: String = "delete", + ) + + /** + * Pause an AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun pauseAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Query("command") command: String = "pause", + ) + + /** + * Resume a paused AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun resumeAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Query("command") command: String = "resume", + ) + + /** + * Get AutoPay payment history + */ + @GET("${ApiEndPoints.AUTO_PAY}/{autoPayId}/history") + fun getAutoPayHistory( + @Path("autoPayId") autoPayId: Long, + @Query("limit") limit: Int = 20, + ): Flow> + + /** + * Get upcoming payments for all AutoPay schedules + */ + @GET("${ApiEndPoints.AUTO_PAY}/upcoming-payments") + fun getUpcomingPayments( + @Query("clientId") clientId: Long, + @Query("limit") limit: Int = 10, + ): Flow> + + /** + * Get AutoPay statistics for dashboard + */ + @GET("${ApiEndPoints.AUTO_PAY}/statistics") + fun getAutoPayStatistics( + @Query("clientId") clientId: Long, + ): Flow +} + +data class AutoPayStatisticsResponse( + val totalActiveSchedules: Int = 0, + val totalPausedSchedules: Int = 0, + val totalCompletedSchedules: Int = 0, + val totalUpcomingPayments: Int = 0, + val totalAmountThisMonth: Double = 0.0, + val currency: String = "USD", +) diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt index 19d8424b2..23e722443 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt @@ -27,4 +27,7 @@ object ApiEndPoints { const val RUN_REPORT = "runreports" const val USER = "users" const val STANDING_INSTRUCTION = "standinginstructions" + + // TODO: Verify with backend team and update according to finalized API contract + const val AUTO_PAY = "autopay" } diff --git a/feature/autopay/README.md b/feature/autopay/README.md index 1f0e4403d..6fb08aaca 100644 --- a/feature/autopay/README.md +++ b/feature/autopay/README.md @@ -3,48 +3,6 @@ ## Overview The AutoPay feature module provides functionality for setting up and managing automatic payment schedules. This module allows users to configure recurring payments, set up payment rules, and manage their automatic payment preferences. -## Features -- **AutoPay Dashboard**: Main screen displaying active schedules, upcoming payments, and quick actions -- Set up recurring payment schedules -- Configure payment rules and conditions -- Manage automatic payment preferences -- View payment history and status -- Enable/disable automatic payments -- Pull-to-refresh functionality -- Schedule details view with management actions - -## AutoPay Dashboard - -The main AutoPay Dashboard screen provides a comprehensive view of all AutoPay activities: - -### Dashboard Components -- **Dashboard Header**: Shows total active schedules and upcoming payments with visual statistics -- **Quick Actions**: Add new schedule and manage existing schedules buttons -- **Active Schedules**: List of all active AutoPay schedules with status indicators -- **Upcoming Payments**: List of scheduled payments with due dates -- **Pull-to-Refresh**: Swipe down to refresh dashboard data -- **Loading States**: Proper loading indicators during data fetch -- **Empty States**: Helpful messages when no schedules or payments exist - -### Schedule Information Displayed -- Schedule name and recipient -- Payment amount and currency -- Frequency (Monthly, Weekly, etc.) -- Next payment date -- Status (Active, Paused, Cancelled, Completed) -- Account number (masked for security) - -### Quick Actions -- **Add New**: Navigate to schedule setup screen -- **Manage**: Navigate to rules and preferences management -- **View Details**: Tap on any schedule to see detailed information - -### Schedule Details Screen -When a user taps on an active schedule, they can view: -- Complete schedule information -- Payment details -- Schedule management actions (Pause/Resume, Edit, Cancel) - ## Screenshots ### Android *Screenshots will be added as the feature is developed* @@ -55,93 +13,4 @@ When a user taps on an active schedule, they can view: ### Web *Screenshots will be added as the feature is developed* -## Module Structure -``` -feature/autopay/ -├── src/ -│ ├── commonMain/ -│ │ ├── kotlin/org/mifospay/feature/autopay/ -│ │ │ ├── di/ -│ │ │ │ └── AutoPayModule.kt -│ │ │ ├── AutoPayScreen.kt (Dashboard) -│ │ │ ├── AutoPayScheduleDetailsScreen.kt -│ │ │ ├── AutoPayNavigation.kt -│ │ │ └── AutoPayViewModel.kt -│ │ └── composeResources/ -│ └── androidMain/ -│ └── kotlin/org/mifospay/feature/autopay/ -├── build.gradle.kts -└── README.md -``` - -## Data Models - -### AutoPaySchedule -```kotlin -data class AutoPaySchedule( - val id: String, - val name: String, - val amount: Double, - val currency: String, - val frequency: String, - val nextPaymentDate: String, - val status: AutoPayStatus, - val recipientName: String, - val accountNumber: String, -) -``` - -### UpcomingPayment -```kotlin -data class UpcomingPayment( - val id: String, - val scheduleName: String, - val amount: Double, - val currency: String, - val dueDate: String, - val status: PaymentStatus, - val recipientName: String, -) -``` - -### Status Enums -```kotlin -enum class AutoPayStatus { - ACTIVE, PAUSED, CANCELLED, COMPLETED -} - -enum class PaymentStatus { - UPCOMING, PROCESSING, COMPLETED, FAILED -} -``` - -## Dependencies -- Compose UI components -- Material3 design system -- Koin dependency injection -- Core domain modules (as needed) - -## Usage -This module is designed to be integrated into the main application through dependency injection. The AutoPayModule provides the necessary dependencies for the AutoPay feature. - -### Navigation -The AutoPay feature includes the following navigation routes: -- `autopay` - Main dashboard -- `autopay/setup` - Setup new schedule -- `autopay/rules` - Manage rules -- `autopay/preferences` - Manage preferences -- `autopay/history` - View history -- `autopay/schedule/{scheduleId}` - Schedule details - -## Development Status -✅ **Dashboard Implementation Complete** - AutoPay Dashboard with all required features implemented -- ✅ Display list of active AutoPay schedules -- ✅ Show upcoming payments with due dates -- ✅ Display quick action buttons (Add New, Manage Existing) -- ✅ Show payment status indicators -- ✅ Implement pull-to-refresh functionality -- ✅ Handle loading and error states -- ✅ Schedule details screen -- ✅ Dummy data for demonstration -🚧 **Additional Features** - Setup, Rules, Preferences, and History screens need implementation diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt index ef2359b13..f075c092c 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt @@ -68,7 +68,7 @@ fun AutoPayHistoryScreen( Text( text = "AutoPay History", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( @@ -139,7 +139,7 @@ private fun HistoryItemCard( Text( text = historyItem.amount, style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt index 888509fbc..987726754 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt @@ -13,17 +13,18 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.NavType -import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navOptions +import org.mifospay.core.ui.composableWithSlideTransitions object AutoPayNavigation { - const val AUTO_PAY_ROUTE = "autopay" - const val AUTO_PAY_SETUP_ROUTE = "autopay/setup" - const val AUTO_PAY_RULES_ROUTE = "autopay/rules" - const val AUTO_PAY_PREFERENCES_ROUTE = "autopay/preferences" - const val AUTO_PAY_HISTORY_ROUTE = "autopay/history" - const val AUTO_PAY_SCHEDULE_DETAILS_ROUTE = "autopay/schedule/{scheduleId}" + const val AUTO_PAY_ROUTE = "autopay_route" + const val AUTO_PAY_SETUP_ROUTE = "autopay_setup_route" + const val AUTO_PAY_RULES_ROUTE = "autopay_rules_route" + const val AUTO_PAY_PREFERENCES_ROUTE = "autopay_preferences_route" + const val AUTO_PAY_HISTORY_ROUTE = "autopay_history_route" + const val AUTO_PAY_SCHEDULE_DETAILS_ROUTE = "autopay_schedule_details_route" + const val SCHEDULE_ID_ARG = "scheduleId" } fun NavController.navigateToAutoPay(navOptions: NavOptions? = null) { @@ -47,14 +48,15 @@ fun NavController.navigateToAutoPayHistory(navOptions: NavOptions? = null) { } fun NavController.navigateToAutoPayScheduleDetails(scheduleId: String, navOptions: NavOptions? = null) { - navigate(AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE.replace("{scheduleId}", scheduleId), navOptions) + val route = "${AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE}?${AutoPayNavigation.SCHEDULE_ID_ARG}=$scheduleId" + navigate(route, navOptions) } fun NavGraphBuilder.autoPayGraph( navController: NavController, onNavigateBack: () -> Unit = { navController.navigateUp() }, ) { - composable(AutoPayNavigation.AUTO_PAY_ROUTE) { + composableWithSlideTransitions(AutoPayNavigation.AUTO_PAY_ROUTE) { AutoPayScreen( onNavigateToSetup = { navController.navigateToAutoPaySetup() @@ -71,37 +73,39 @@ fun NavGraphBuilder.autoPayGraph( onNavigateToScheduleDetails = { scheduleId -> navController.navigateToAutoPayScheduleDetails(scheduleId) }, + onNavigateBack = onNavigateBack, + showTopBar = true, ) } - composable(AutoPayNavigation.AUTO_PAY_SETUP_ROUTE) { + composableWithSlideTransitions(AutoPayNavigation.AUTO_PAY_SETUP_ROUTE) { AutoPaySetupScreen( onNavigateBack = onNavigateBack, ) } - composable(AutoPayNavigation.AUTO_PAY_RULES_ROUTE) { + composableWithSlideTransitions(AutoPayNavigation.AUTO_PAY_RULES_ROUTE) { AutoPayRulesScreen( onNavigateBack = onNavigateBack, ) } - composable(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE) { + composableWithSlideTransitions(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE) { AutoPayPreferencesScreen( onNavigateBack = onNavigateBack, ) } - composable(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE) { + composableWithSlideTransitions(AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE) { AutoPayHistoryScreen( onNavigateBack = onNavigateBack, ) } - composable( - route = AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE, + composableWithSlideTransitions( + route = "${AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE}?${AutoPayNavigation.SCHEDULE_ID_ARG}={${AutoPayNavigation.SCHEDULE_ID_ARG}}", arguments = listOf( - navArgument("scheduleId") { + navArgument(AutoPayNavigation.SCHEDULE_ID_ARG) { type = NavType.StringType nullable = false }, diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt index 99f097922..2d63ad123 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayPreferencesScreen.kt @@ -71,7 +71,7 @@ fun AutoPayPreferencesScreen( Text( text = "AutoPay Preferences", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( @@ -159,7 +159,7 @@ private fun PreferencesSection( Text( text = title, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Column( diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt index 6a5e150b2..742e7a552 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayRulesScreen.kt @@ -65,7 +65,7 @@ fun AutoPayRulesScreen( Text( text = "AutoPay Rules & Policies", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( @@ -141,7 +141,7 @@ private fun RulesSection( Text( text = title, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Column( @@ -167,7 +167,7 @@ private fun RuleItem( Text( text = "•", style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, modifier = Modifier.padding(end = 8.dp), ) diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt index e0687b9fd..d09056c2c 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt @@ -85,7 +85,7 @@ fun AutoPayScheduleDetailsScreen( Text( text = "Schedule Not Found", style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(8.dp)) @@ -173,7 +173,7 @@ private fun ScheduleHeaderCard( Text( text = schedule.name, style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onPrimaryContainer, ) @@ -186,7 +186,7 @@ private fun ScheduleHeaderCard( Text( text = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onPrimaryContainer, ) @@ -217,7 +217,7 @@ private fun ScheduleInfoCard( Text( text = "Schedule Information", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(16.dp)) @@ -267,7 +267,7 @@ private fun PaymentDetailsCard( Text( text = "Payment Details", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(16.dp)) @@ -308,7 +308,7 @@ private fun ScheduleActionsCard( Text( text = "Actions", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt index 345ba5f4c..fecd4a678 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt @@ -51,6 +51,8 @@ fun AutoPayScreen( onNavigateToPreferences: () -> Unit, onNavigateToHistory: () -> Unit, onNavigateToScheduleDetails: (String) -> Unit, + onNavigateBack: () -> Unit = {}, + showTopBar: Boolean = true, modifier: Modifier = Modifier, viewModel: AutoPayViewModel = koinViewModel(), ) { @@ -64,8 +66,8 @@ fun AutoPayScreen( MifosScaffold( modifier = modifier, - topBarTitle = "AutoPay Dashboard", - backPress = { /* Handle back navigation */ }, + topBarTitle = if (showTopBar) "AutoPay Dashboard" else null, + backPress = onNavigateBack, pullToRefreshState = pullRefreshState, ) { paddingValues -> if (state.isLoading && state.activeSchedules.isEmpty()) { @@ -145,7 +147,7 @@ private fun AutoPayDashboardContent( Text( text = "Active Schedules", style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) } @@ -171,7 +173,7 @@ private fun AutoPayDashboardContent( Text( text = "Upcoming Payments", style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) } @@ -210,15 +212,6 @@ private fun DashboardHeader( modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = "AutoPay Dashboard", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - - Spacer(modifier = Modifier.height(16.dp)) - Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, @@ -262,7 +255,7 @@ private fun DashboardStat( Text( text = value, style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onPrimaryContainer, ) @@ -293,7 +286,7 @@ private fun QuickActionsSection( Text( text = "Quick Actions", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(12.dp)) @@ -379,7 +372,7 @@ private fun ActiveScheduleCard( Text( text = schedule.name, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( @@ -407,7 +400,7 @@ private fun ActiveScheduleCard( Text( text = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) } @@ -465,7 +458,7 @@ private fun UpcomingPaymentCard( Text( text = payment.scheduleName, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( @@ -493,7 +486,7 @@ private fun UpcomingPaymentCard( Text( text = CurrencyFormatter.format(payment.amount, payment.currency, 2), style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) } @@ -597,7 +590,7 @@ private fun EmptyStateCard( Text( text = title, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt index b8205f5ac..bd323f59e 100644 --- a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySetupScreen.kt @@ -60,7 +60,7 @@ fun AutoPaySetupScreen( Text( text = "AutoPay Setup", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text( @@ -85,7 +85,7 @@ fun AutoPaySetupScreen( Text( text = "Setup Options", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ) Text(