diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticated/AuthenticatedNavigation.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticated/AuthenticatedNavigation.kt index 0bb6e12511..8e84927623 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticated/AuthenticatedNavigation.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticated/AuthenticatedNavigation.kt @@ -22,6 +22,7 @@ import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.Serializable import org.mifos.mobile.core.common.Constants import org.mifos.mobile.core.model.entity.TransferSuccessDestination +import org.mifos.mobile.core.model.enums.TransferType import org.mifos.mobile.feature.accounts.accountTransactions.accountTransactionsDestination import org.mifos.mobile.feature.accounts.accountTransactions.navigateToAccountTransactionsScreen import org.mifos.mobile.feature.accounts.accounts.accountsDestination @@ -59,7 +60,7 @@ import org.mifos.mobile.feature.share.application.navigation.navigateToShareAppl import org.mifos.mobile.feature.share.application.navigation.shareApplicationNavGraph import org.mifos.mobile.feature.status.navigation.StatusNavigationRoute import org.mifos.mobile.feature.status.navigation.statusDestination -import org.mifos.mobile.feature.third.party.transfer.navigation.thirdPartyTransferNavGraph +import org.mifos.mobile.feature.third.party.transfer.navigation.TptNavigationDestination import org.mifos.mobile.feature.transfer.process.makeTransfer.makeTransferDestination import org.mifos.mobile.feature.transfer.process.makeTransfer.navigateToMakeTransferScreen import org.mifos.mobile.feature.transfer.process.transferProcess.navigateToTransferProcessScreen @@ -72,6 +73,7 @@ internal fun NavController.navigateToAuthenticatedGraph(navOptions: NavOptions? navigate(route = AuthenticatedGraphRoute, navOptions = navOptions) } +@Suppress("CyclomaticComplexMethod") @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) internal fun NavGraphBuilder.authenticatedGraph( navController: NavController, @@ -79,47 +81,66 @@ internal fun NavGraphBuilder.authenticatedGraph( navigation( startDestination = AuthenticatedNavbarRoute, ) { - authenticatedNavbarGraph { destination -> - when (destination) { - is HomeNavigationDestination.AccountsWithType -> { - if (destination.type in listOf( - Constants.SAVINGS_ACCOUNT, - Constants.LOAN_ACCOUNT, - Constants.SHARE_ACCOUNTS, - ) - ) { - navController.navigateToAccountsScreen(destination.type) + authenticatedNavbarGraph( + homeNavigator = { destination -> + when (destination) { + is HomeNavigationDestination.AccountsWithType -> { + if (destination.type in listOf( + Constants.SAVINGS_ACCOUNT, + Constants.LOAN_ACCOUNT, + Constants.SHARE_ACCOUNTS, + ) + ) { + navController.navigateToAccountsScreen(destination.type) + } } - } - is HomeNavigationDestination.Notification -> - navController.navigateToNotificationScreen() + is HomeNavigationDestination.Notification -> + navController.navigateToNotificationScreen() - is HomeNavigationDestination.Charge -> - navController.navigateToChargeGraph() + is HomeNavigationDestination.Charge -> + navController.navigateToChargeGraph() - is HomeNavigationDestination.Faq -> - navController.navigateToFaq() + is HomeNavigationDestination.Faq -> + navController.navigateToFaq() - is HomeNavigationDestination.Beneficiary -> - navController.navigateToBeneficiaryNavGraph() + is HomeNavigationDestination.Beneficiary -> + navController.navigateToBeneficiaryNavGraph() - is HomeNavigationDestination.Transaction -> - navController.navigateToAccountTransactionsScreen( - Constants.RECENT_TRANSACTIONS, - -1L, - ) + is HomeNavigationDestination.Transaction -> + navController.navigateToAccountTransactionsScreen( + Constants.RECENT_TRANSACTIONS, + -1L, + ) - is HomeNavigationDestination.ApplyLoan -> - navController.navigateToLoanApplicationGraph() + is HomeNavigationDestination.ApplyLoan -> + navController.navigateToLoanApplicationGraph() - is HomeNavigationDestination.ApplySavings -> - navController.navigateToSavingsApplicationGraph() + is HomeNavigationDestination.ApplySavings -> + navController.navigateToSavingsApplicationGraph() - is HomeNavigationDestination.ApplyShare -> - navController.navigateToShareApplicationGraph() - } - } + is HomeNavigationDestination.ApplyShare -> + navController.navigateToShareApplicationGraph() + } + }, + + tptNavigator = { destination -> + when (destination) { + TptNavigationDestination.Notification -> navController.navigateToNotificationScreen() + + is TptNavigationDestination.TransferProcess -> { + navController.navigateToTransferProcessScreen( + destination.payload, + TransferType.TPT, + TransferSuccessDestination.TRANSFER_TAB.name, + ) + } + else -> { + navController.navigateToManualBeneficiaryAddScreen() + } + } + }, + ) notificationDestination( navigateBack = navController::popBackStack, @@ -242,23 +263,12 @@ internal fun NavGraphBuilder.authenticatedGraph( TransferSuccessDestination.SAVINGS_ACCOUNT -> Constants.NAVIGATE_BACK_TO_SAVINGS TransferSuccessDestination.LOAN_ACCOUNT -> Constants.NAVIGATE_BACK_TO_LOAN TransferSuccessDestination.HOME -> "" + TransferSuccessDestination.TRANSFER_TAB -> Constants.TRANSFER_TAB }, ) }, ) - thirdPartyTransferNavGraph( - navigateBack = navController::popBackStack, - addBeneficiary = { }, - reviewTransfer = { transferPayload, transferType, transferDestination -> - navController.navigateToTransferProcessScreen( - transferPayload, - transferType, - transferDestination.name, - ) - }, - ) - transferProcessDestination( navigateBack = navController::popBackStack, navigateToAuthenticateScreen = navController::navigateToVerifyPasscodeScreen, diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavBarTabItem.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavBarTabItem.kt index 13687fc68f..01290eb37c 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavBarTabItem.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavBarTabItem.kt @@ -16,9 +16,11 @@ import org.mifos.mobile.core.designsystem.icon.MifosIcons import org.mifos.mobile.core.ui.navigation.NavigationItem import org.mifos.mobile.feature.home.navigation.HomeRoute import org.mifos.mobile.feature.settings.navigation.SettingsNavGraphRoute +import org.mifos.mobile.feature.third.party.transfer.navigation.ThirdPartyTransferNavGraphRoute import org.mifos.mobile.navigation.generated.resources.Res import org.mifos.mobile.navigation.generated.resources.home import org.mifos.mobile.navigation.generated.resources.profile +import org.mifos.mobile.navigation.generated.resources.transfer sealed class AuthenticatedNavBarTabItem : NavigationItem { @@ -41,22 +43,22 @@ sealed class AuthenticatedNavBarTabItem : NavigationItem { // TODO Add Top level destinations here -// data object TransferTab : AuthenticatedNavBarTabItem() { -// override val iconResSelected: ImageVector -// get() = MifosIcons.TransferTab -// override val iconRes: ImageVector -// get() = MifosIcons.TransferTab -// override val labelRes: StringResource -// get() = Res.string.transfer -// override val contentDescriptionRes: StringResource -// get() = Res.string.transfer -// override val graphRoute: String -// get() = transferNavRoute.toObjectNavigationRoute() -// override val startDestinationRoute: String -// get() = transferNavRoute.toObjectNavigationRoute() -// override val testTag: String -// get() = "TransferTab" -// } + data object TransferTab : AuthenticatedNavBarTabItem() { + override val iconResSelected: ImageVector + get() = MifosIcons.MoneyHand + override val iconRes: ImageVector + get() = MifosIcons.MoneyHand + override val labelRes: StringResource + get() = Res.string.transfer + override val contentDescriptionRes: StringResource + get() = Res.string.transfer + override val graphRoute: String + get() = ThirdPartyTransferNavGraphRoute.toObjectNavigationRoute() + override val startDestinationRoute: String + get() = ThirdPartyTransferNavGraphRoute.toObjectNavigationRoute() + override val testTag: String + get() = "TransferTab" + } data object ProfileTab : AuthenticatedNavBarTabItem() { override val iconResSelected: ImageVector diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigation.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigation.kt index 136d025cb6..8b5e0f1cef 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigation.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigation.kt @@ -17,6 +17,7 @@ import androidx.navigation.NavOptions import kotlinx.serialization.Serializable import org.mifos.mobile.core.ui.composableWithStayTransitions import org.mifos.mobile.feature.home.navigation.HomeNavigator +import org.mifos.mobile.feature.third.party.transfer.navigation.TptNavigator @Serializable data object AuthenticatedNavbarRoute @@ -27,10 +28,13 @@ internal fun NavController.navigateToAuthenticatedNavBar(navOptions: NavOptions? internal fun NavGraphBuilder.authenticatedNavbarGraph( homeNavigator: HomeNavigator, + tptNavigator: TptNavigator, ) { composableWithStayTransitions { AuthenticatedNavbarNavigationScreen( homeNavigator = homeNavigator, + tptNavigator = tptNavigator, + ) } } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationScreen.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationScreen.kt index 4c554e6f5f..cfce9f2747 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationScreen.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationScreen.kt @@ -46,12 +46,16 @@ import org.mifos.mobile.feature.home.navigation.homeDestination import org.mifos.mobile.feature.home.navigation.navigateToHomeScreen import org.mifos.mobile.feature.settings.navigation.navigateToSettingsGraph import org.mifos.mobile.feature.settings.navigation.settingsGraph +import org.mifos.mobile.feature.third.party.transfer.navigation.TptNavigator +import org.mifos.mobile.feature.third.party.transfer.navigation.navigateToTptGraph +import org.mifos.mobile.feature.third.party.transfer.navigation.tptGraphDestination import org.mifos.mobile.navigation.generated.resources.Res import org.mifos.mobile.navigation.generated.resources.not_connected @Composable internal fun AuthenticatedNavbarNavigationScreen( homeNavigator: HomeNavigator, + tptNavigator: TptNavigator, modifier: Modifier = Modifier, navController: NavHostController = rememberMifosNavController( name = "AuthenticatedNavbarScreen", @@ -72,6 +76,12 @@ internal fun AuthenticatedNavbarNavigationScreen( } } + AuthenticatedNavBarEvent.NavigateToThirdPartyTransferScreen -> { + navigateToTabOrRoot(tabToNavigateTo = event.tab) { + navigateToTptGraph(navOptions = it) + } + } + AuthenticatedNavBarEvent.NavigateToUserProfileScreen -> { navigateToTabOrRoot(tabToNavigateTo = event.tab) { navigateToSettingsGraph(navOptions = it) @@ -103,6 +113,7 @@ internal fun AuthenticatedNavbarNavigationScreen( { viewModel.trySendAction(it) } }, homeNavigator = homeNavigator, + tptNavigator = tptNavigator, ) } @@ -110,6 +121,7 @@ internal fun AuthenticatedNavbarNavigationScreen( internal fun AuthenticatedNavbarNavigationScreenContent( navController: NavHostController, homeNavigator: HomeNavigator, + tptNavigator: TptNavigator, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onAction: (AuthenticatedNavBarAction) -> Unit, @@ -117,6 +129,7 @@ internal fun AuthenticatedNavbarNavigationScreenContent( val navBackStackEntry by navController.currentBackStackEntryAsState() val navigationItems = persistentListOf( AuthenticatedNavBarTabItem.HomeTab, + AuthenticatedNavBarTabItem.TransferTab, AuthenticatedNavBarTabItem.ProfileTab, ) @@ -137,6 +150,10 @@ internal fun AuthenticatedNavbarNavigationScreenContent( is AuthenticatedNavBarTabItem.ProfileTab -> { onAction(AuthenticatedNavBarAction.ProfileTabClick) } + + is AuthenticatedNavBarTabItem.TransferTab -> { + onAction(AuthenticatedNavBarAction.TransferTabClick) + } } }, shouldShowNavigation = navigationItems.any { @@ -162,6 +179,10 @@ internal fun AuthenticatedNavbarNavigationScreenContent( // TODO Add top level destination screens homeDestination(onNavigate = homeNavigator) + tptGraphDestination( + onNavigate = tptNavigator, + ) + settingsGraph( navController = navController, ) diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationViewModel.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationViewModel.kt index 9527dd88b5..a71deb9a1a 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/authenticatednavbar/AuthenticatedNavbarNavigationViewModel.kt @@ -38,6 +38,8 @@ internal class AuthenticatedNavbarNavigationViewModel( is AuthenticatedNavBarAction.Internal -> handleInternalAction(action) AuthenticatedNavBarAction.ProfileTabClick -> handleProfileTabClicked() + + AuthenticatedNavBarAction.TransferTabClick -> handleTransferTabClicked() } } @@ -45,6 +47,10 @@ internal class AuthenticatedNavbarNavigationViewModel( sendEvent(AuthenticatedNavBarEvent.NavigateToHomeScreen) } + private fun handleTransferTabClicked() { + sendEvent(AuthenticatedNavBarEvent.NavigateToThirdPartyTransferScreen) + } + private fun handleProfileTabClicked() { sendEvent(AuthenticatedNavBarEvent.NavigateToUserProfileScreen) } @@ -65,6 +71,8 @@ internal sealed class AuthenticatedNavBarAction { data object ProfileTabClick : AuthenticatedNavBarAction() + data object TransferTabClick : AuthenticatedNavBarAction() + sealed class Internal : AuthenticatedNavBarAction() { data class UserStateUpdateReceive(val appSettings: AppSettings?) : Internal() } @@ -78,6 +86,10 @@ internal sealed class AuthenticatedNavBarEvent { override val tab: AuthenticatedNavBarTabItem = AuthenticatedNavBarTabItem.HomeTab } + data object NavigateToThirdPartyTransferScreen : AuthenticatedNavBarEvent() { + override val tab: AuthenticatedNavBarTabItem = AuthenticatedNavBarTabItem.TransferTab + } + data object NavigateToUserProfileScreen : AuthenticatedNavBarEvent() { override val tab: AuthenticatedNavBarTabItem = AuthenticatedNavBarTabItem.ProfileTab } diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt index 7ccb322673..678fe27137 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt @@ -114,4 +114,5 @@ object Constants { const val APPLY_SAVINGS = "apply_savings" const val APPLY_SHARE = "apply_share" + const val TRANSFER_TAB = "transfer_tab" } diff --git a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/TransferSuccessDestination.kt b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/TransferSuccessDestination.kt index e68cfb9eb0..4f34cca958 100644 --- a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/TransferSuccessDestination.kt +++ b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/TransferSuccessDestination.kt @@ -13,4 +13,5 @@ enum class TransferSuccessDestination { SAVINGS_ACCOUNT, LOAN_ACCOUNT, HOME, + TRANSFER_TAB, } diff --git a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/payload/ReviewTransferPayload.kt b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/payload/ReviewTransferPayload.kt index 5cf0eb8125..6f0819e212 100644 --- a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/payload/ReviewTransferPayload.kt +++ b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/payload/ReviewTransferPayload.kt @@ -9,8 +9,10 @@ */ package org.mifos.mobile.core.model.entity.payload +import kotlinx.serialization.Serializable import org.mifos.mobile.core.model.entity.templates.account.AccountOption +@Serializable data class ReviewTransferPayload( val payToAccount: AccountOption? = null, val payFromAccount: AccountOption? = null, diff --git a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/enums/AccountType.kt b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/enums/AccountType.kt index 84e3966709..5840500a68 100644 --- a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/enums/AccountType.kt +++ b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/enums/AccountType.kt @@ -14,11 +14,11 @@ package org.mifos.mobile.core.model.enums * On 24/03/17. */ -enum class AccountType { +enum class AccountType(val value: String) { - SAVINGS, + SAVINGS("Savings Account"), - LOAN, + LOAN("Loan Account"), - SHARE, + SHARE("Share Account"), } diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/component/MifosDropDownPayFromComponent.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/component/MifosDropDownPayFromComponent.kt index c9cd163bce..c04c44efc3 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/component/MifosDropDownPayFromComponent.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/component/MifosDropDownPayFromComponent.kt @@ -24,9 +24,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import mifos_mobile.core.ui.generated.resources.Res import mifos_mobile.core.ui.generated.resources.available_balance import mifos_mobile.core.ui.generated.resources.ic_icon_dashboard @@ -53,13 +52,13 @@ import org.mifos.mobile.core.designsystem.theme.AppColors import org.mifos.mobile.core.designsystem.theme.DesignToken import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme import org.mifos.mobile.core.designsystem.theme.MifosTypography -import org.mifos.mobile.core.designsystem.utils.onClick @Composable fun MifosPayFromDropdownUI( accounts: List>, - modifier: Modifier = Modifier, onAccountSelected: (String, String) -> Unit, + modifier: Modifier = Modifier, + label: String = stringResource(Res.string.select_other_payment_account), ) { var selectedAccount by rememberSaveable { mutableStateOf("") } var selectedBalance by rememberSaveable { mutableStateOf("") } @@ -68,6 +67,7 @@ fun MifosPayFromDropdownUI( accountNumber = selectedAccount, availableBalance = selectedBalance, modifier = modifier, + label = label, ) AccountDropdownList( accounts = accounts, @@ -85,12 +85,13 @@ fun MifosPayFromDropdownUI( fun MifosDropDownPayFromComponent( accountNumber: String, availableBalance: String, + label: String, modifier: Modifier = Modifier, ) { - Box(modifier = modifier) { + Box(modifier = modifier.padding(top = DesignToken.padding.small)) { Box( modifier = Modifier - .clip(RoundedCornerShape(16.dp)) + .clip(DesignToken.shapes.large) .height(128.dp) .fillMaxWidth(), ) { @@ -140,16 +141,18 @@ fun MifosDropDownPayFromComponent( Box( modifier = Modifier .align(Alignment.TopStart) - .offset(x = 16.dp, y = (-8).dp) + .offset(x = 16.dp, y = (-DesignToken.padding.small)) + .zIndex(1f) .background( color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(12.dp), + shape = DesignToken.shapes.medium, ) .padding(horizontal = DesignToken.padding.small), ) { Text( - text = "Pay From", + text = label, style = MifosTypography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -194,7 +197,6 @@ fun AccountDropdownList( Column( modifier = modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .background(MaterialTheme.colorScheme.tertiary), ) { @@ -259,16 +261,22 @@ fun AccountDropdownList( @Composable private fun MifosDropDownPayFromComponentPreview() { MifosMobileTheme { - MifosPayFromDropdownUI( - accounts = listOf( - "267282972" to "$ 23,786.00", - "6572992762" to "$ 123,786.00", - "52682926" to "$ 78,786.00", - "678292726" to "$ 923,786.00", - ), - onAccountSelected = { - _, _ -> - }, - ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(DesignToken.padding.large), + ) { + MifosPayFromDropdownUI( + accounts = listOf( + "267282972" to "$ 23,786.00", + "6572992762" to "$ 123,786.00", + "52682926" to "$ 78,786.00", + "678292726" to "$ 923,786.00", + ), + onAccountSelected = { + _, _ -> + }, + ) + } } } diff --git a/feature/third-party-transfer/src/commonMain/composeResources/values/strings.xml b/feature/third-party-transfer/src/commonMain/composeResources/values/strings.xml index dd8b05221d..64dc64b74c 100644 --- a/feature/third-party-transfer/src/commonMain/composeResources/values/strings.xml +++ b/feature/third-party-transfer/src/commonMain/composeResources/values/strings.xml @@ -9,33 +9,23 @@ See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md --> - Please make sure you are connected to internet - Third Party Transfer - Error to fetch Third Party Transfertemplate - 1 - 2 - 3 - 4 - Enter Amount - Amount should be greater than zero - Pay To - Pay From - Select Account to Pay To - Select Account to Pay From - Required - Continue - Invalid Amount - Amount - Review - Enter Remarks for transfer - Transfer - Remark is mandatory - Remark - Cancel - Select Beneficiary - Add Beneficiary - Beneficiary - Currently, you don\'t have any Beneficiary. Please add Beneficiary - Loan Type + + Amount is required + Please enter a valid amount + Remarks cannot be empty + Invalid remarks entered + Server issue from our side cannot proceed further, Try again after a moment + + + Origin Account + Select other payment account + Destination Account + Amount + Remarks + Not finding destination account? + Add Beneficiary! + + + Transfer Amount \ No newline at end of file diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferContent.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferContent.kt deleted file mode 100644 index aefab03322..0000000000 --- a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferContent.kt +++ /dev/null @@ -1,435 +0,0 @@ -/* - * 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-mobile/blob/master/LICENSE.md - */ -package org.mifos.mobile.feature.third.party.transfer - -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.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color.Companion.DarkGray -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp -import mifos_mobile.feature.third_party_transfer.generated.resources.Res -import mifos_mobile.feature.third_party_transfer.generated.resources.add_beneficiary -import mifos_mobile.feature.third_party_transfer.generated.resources.amount -import mifos_mobile.feature.third_party_transfer.generated.resources.amount_greater_than_zero -import mifos_mobile.feature.third_party_transfer.generated.resources.beneficiary -import mifos_mobile.feature.third_party_transfer.generated.resources.cancel -import mifos_mobile.feature.third_party_transfer.generated.resources.continue_str -import mifos_mobile.feature.third_party_transfer.generated.resources.enter_amount -import mifos_mobile.feature.third_party_transfer.generated.resources.enter_remarks -import mifos_mobile.feature.third_party_transfer.generated.resources.four -import mifos_mobile.feature.third_party_transfer.generated.resources.invalid_amount -import mifos_mobile.feature.third_party_transfer.generated.resources.loan_type -import mifos_mobile.feature.third_party_transfer.generated.resources.no_beneficiary_found_please_add -import mifos_mobile.feature.third_party_transfer.generated.resources.one -import mifos_mobile.feature.third_party_transfer.generated.resources.pay_from -import mifos_mobile.feature.third_party_transfer.generated.resources.remark -import mifos_mobile.feature.third_party_transfer.generated.resources.remark_is_mandatory -import mifos_mobile.feature.third_party_transfer.generated.resources.required -import mifos_mobile.feature.third_party_transfer.generated.resources.review -import mifos_mobile.feature.third_party_transfer.generated.resources.select_beneficiary -import mifos_mobile.feature.third_party_transfer.generated.resources.select_pay_from -import mifos_mobile.feature.third_party_transfer.generated.resources.select_pay_to -import mifos_mobile.feature.third_party_transfer.generated.resources.three -import mifos_mobile.feature.third_party_transfer.generated.resources.two -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview -import org.mifos.mobile.core.designsystem.component.MifosButton -import org.mifos.mobile.core.designsystem.component.MifosOutlinedButton -import org.mifos.mobile.core.designsystem.component.MifosOutlinedTextField -import org.mifos.mobile.core.designsystem.component.MifosTextField -import org.mifos.mobile.core.designsystem.component.MifosTextFieldConfig -import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme -import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary -import org.mifos.mobile.core.model.entity.payload.ReviewTransferPayload -import org.mifos.mobile.core.model.entity.templates.account.AccountOption -import org.mifos.mobile.core.model.enums.TransferType -import org.mifos.mobile.core.ui.component.MFStepProcess -import org.mifos.mobile.core.ui.component.MifosDropDownDoubleTextField -import org.mifos.mobile.core.ui.component.StepProcessState -import org.mifos.mobile.core.ui.component.getStepState - -@Composable -internal fun ThirdPartyTransferContent( - state: ThirdPartyTransferState, - onAction: (ThirdPartyTransferAction) -> Unit, - modifier: Modifier = Modifier, -) { - val scrollState = rememberScrollState() - - var payFromAccount by rememberSaveable { mutableStateOf(null) } - var beneficiary by rememberSaveable { mutableStateOf(null) } - var amount by rememberSaveable { mutableStateOf("") } - var remark by rememberSaveable { mutableStateOf("") } - - var currentStep by rememberSaveable { mutableIntStateOf(0) } - - val payFromStepState by remember { - derivedStateOf { getStepState(targetStep = 0, currentStep = currentStep) } - } - - val beneficiaryStepState by remember { - derivedStateOf { getStepState(targetStep = 1, currentStep = currentStep) } - } - - val amountStepState by remember { - derivedStateOf { getStepState(targetStep = 2, currentStep = currentStep) } - } - - val remarkStepState by remember { - derivedStateOf { getStepState(targetStep = 3, currentStep = currentStep) } - } - - val stepsState = listOf( - Pair(payFromStepState, Res.string.one), - Pair(beneficiaryStepState, Res.string.two), - Pair(amountStepState, Res.string.three), - Pair(remarkStepState, Res.string.four), - ) - - Column( - modifier = modifier - .verticalScroll(scrollState) - .padding(horizontal = 12.dp) - .fillMaxSize(), - ) { - for (step in stepsState) { - MFStepProcess( - stepNumber = stringResource(step.second), - activateColor = MaterialTheme.colorScheme.primary, - processState = step.first, - deactivateColor = DarkGray, - isLastStep = step == stepsState.last(), - ) { stepModifier -> - when (step.second) { - Res.string.one -> state.fromAccountDetail?.let { - PayFromStep( - fromAccountOptions = it, - processState = payFromStepState, - onContinueClick = { - payFromAccount = it - currentStep += 1 - }, - modifier = stepModifier, - ) - } - - Res.string.two -> state.beneficiaries?.let { - BeneficiaryStep( - beneficiaryList = it, - processState = beneficiaryStepState, - addBeneficiary = { onAction(ThirdPartyTransferAction.OnAddBeneficiary) }, - onContinueClick = { - beneficiary = it - currentStep += 1 - }, - modifier = stepModifier, - ) - } - - Res.string.three -> EnterAmountStep( - processState = amountStepState, - onContinueClick = { - amount = it - currentStep += 1 - }, - modifier = stepModifier, - ) - - Res.string.four -> RemarkStep( - processState = remarkStepState, - onContinueClicked = { - remark = it - onAction( - ThirdPartyTransferAction.OnReviewTransfer( - ReviewTransferPayload( - payFromAccount = payFromAccount!!, - payToAccount = state.toAccountOption - ?.firstOrNull { account -> - account.accountNo == beneficiary?.accountNumber - } ?: AccountOption(), - amount = amount, - review = remark, - ), - TransferType.TPT, - ), - ) - }, - modifier = stepModifier, - onCancelledClicked = { onAction(ThirdPartyTransferAction.OnNavigate) }, - ) - } - } - } - } -} - -@Composable -private fun PayFromStep( - fromAccountOptions: List, - processState: StepProcessState, - onContinueClick: (AccountOption) -> Unit, - modifier: Modifier = Modifier, -) { - var payFromAccount by rememberSaveable { mutableStateOf(null) } - var payFromError by rememberSaveable { mutableStateOf(false) } - - Column(modifier = modifier) { - Text( - text = stringResource(Res.string.pay_from), - fontWeight = FontWeight.Bold, - ) - if (processState == StepProcessState.ACTIVE) { - MifosDropDownDoubleTextField( - optionsList = fromAccountOptions - .filter { it.accountType?.value != stringResource(Res.string.loan_type) } - .map { Pair(it.accountNo ?: "", it.accountType?.value ?: "") }, - selectedOption = payFromAccount?.accountNo ?: "", - labelResId = Res.string.select_pay_from, - error = payFromError, - supportingText = stringResource(Res.string.required), - onClick = { index, _ -> - payFromAccount = fromAccountOptions[index] - payFromError = false - }, - ) - MifosButton( - text = { Text(text = stringResource(Res.string.continue_str)) }, - onClick = { - if (payFromAccount == null) { - payFromError = true - } else { - onContinueClick(payFromAccount ?: AccountOption()) - } - }, - ) - } - } -} - -@Composable -private fun BeneficiaryStep( - beneficiaryList: List, - processState: StepProcessState, - addBeneficiary: () -> Unit, - onContinueClick: (Beneficiary) -> Unit, - modifier: Modifier = Modifier, -) { - var beneficiary by rememberSaveable { mutableStateOf(null) } - var beneficiaryError by rememberSaveable { mutableStateOf(false) } - - Column(modifier = modifier) { - Text( - text = stringResource(Res.string.beneficiary), - fontWeight = FontWeight.Bold, - ) - if (processState == StepProcessState.ACTIVE) { - if (beneficiaryList.isEmpty()) { - Text( - text = stringResource(Res.string.no_beneficiary_found_please_add), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelMedium, - ) - MifosButton( - onClick = { addBeneficiary() }, - text = { Text(text = stringResource(Res.string.add_beneficiary)) }, - ) - } else { - MifosDropDownDoubleTextField( - optionsList = beneficiaryList - .map { Pair(it.accountNumber ?: "", it.name ?: "") }, - selectedOption = beneficiary?.accountNumber ?: "", - labelResId = Res.string.select_pay_to, - error = beneficiaryError, - supportingText = stringResource(Res.string.required), - onClick = { index, _ -> - beneficiary = beneficiaryList[index] - beneficiaryError = false - }, - ) - MifosButton( - onClick = { - if (beneficiary == null) { - beneficiaryError = true - } else { - onContinueClick(beneficiary!!) - } - }, - text = { Text(text = stringResource(Res.string.continue_str)) }, - ) - } - } else { - Text( - text = stringResource(Res.string.select_beneficiary), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelMedium, - ) - } - } -} - -@Composable -private fun EnterAmountStep( - processState: StepProcessState, - onContinueClick: (String) -> Unit, - modifier: Modifier = Modifier, -) { - var amount by remember { mutableStateOf(TextFieldValue("")) } - var amountError by remember { mutableStateOf(null) } - var showAmountError by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(key1 = amount) { - showAmountError = false - amountError = when { - amount.text.trim().isEmpty() -> Res.string.enter_amount - amount.text.toDoubleOrNull() == null -> Res.string.invalid_amount - amount.text.toDoubleOrNull() == 0.0 -> Res.string.amount_greater_than_zero - else -> null - } - } - - Column(modifier = modifier) { - Text( - text = stringResource(Res.string.amount), - fontWeight = FontWeight.Bold, - ) - if (processState == StepProcessState.ACTIVE) { - MifosOutlinedTextField( - modifier = Modifier, - value = amount.text, - onValueChange = { amount = TextFieldValue(it) }, - label = stringResource(Res.string.enter_amount), - config = MifosTextFieldConfig( - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - isError = showAmountError, - errorText = amountError?.let { stringResource(it) }, - ), - ) - MifosButton( - onClick = { - if (amountError == null) { - onContinueClick(amount.text) - } else { - showAmountError = true - } - }, - text = { Text(text = stringResource(Res.string.continue_str)) }, - ) - } else { - Text( - text = stringResource(Res.string.enter_amount), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelMedium, - ) - } - } -} - -@Composable -private fun RemarkStep( - processState: StepProcessState, - onContinueClicked: (String) -> Unit, - modifier: Modifier = Modifier, - onCancelledClicked: () -> Unit = {}, -) { - var remark by remember { mutableStateOf(TextFieldValue("")) } - var remarkError by remember { mutableStateOf(null) } - var showRemarkError by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(key1 = remark) { - showRemarkError = false - remarkError = when { - remark.text.trim().isBlank() -> Res.string.remark_is_mandatory - else -> null - } - } - - Column(modifier = modifier) { - Text( - text = stringResource(Res.string.remark), - fontWeight = FontWeight.Bold, - ) - if (processState == StepProcessState.ACTIVE) { - Spacer(modifier = Modifier.height(12.dp)) - MifosTextField( - value = remark.text, - config = MifosTextFieldConfig( - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - ), - isError = showRemarkError, - errorText = remarkError?.let { stringResource(it) }, - ), - onValueChange = { remark = TextFieldValue(it) }, - label = stringResource(Res.string.remark), - ) - Spacer(modifier = Modifier.height(12.dp)) - Row { - MifosButton( - onClick = { - remarkError?.let { showRemarkError = true } - ?: onContinueClicked(remark.text) - }, - text = { Text(text = stringResource(Res.string.review)) }, - ) - Spacer(modifier = Modifier.width(12.dp)) - MifosOutlinedButton( - onClick = { onCancelledClicked() }, - content = { Text(text = stringResource(Res.string.cancel)) }, - - ) - } - } else { - Text( - text = stringResource(Res.string.enter_remarks), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelMedium, - ) - } - } -} - -@Preview -@Composable -private fun ThirdPartyTransferContentPreview() { - MifosMobileTheme { - ThirdPartyTransferContent( - state = ThirdPartyTransferState(dialogState = null), - onAction = { }, - modifier = Modifier, - ) - } -} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferScreen.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferScreen.kt deleted file mode 100644 index 81fc91c735..0000000000 --- a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferScreen.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * 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-mobile/blob/master/LICENSE.md - */ -package org.mifos.mobile.feature.third.party.transfer - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import mifos_mobile.feature.third_party_transfer.generated.resources.Res -import mifos_mobile.feature.third_party_transfer.generated.resources.third_party_transfer -import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview -import org.koin.compose.viewmodel.koinViewModel -import org.mifos.mobile.core.designsystem.component.MifosScaffold -import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme -import org.mifos.mobile.core.model.entity.TransferSuccessDestination -import org.mifos.mobile.core.model.entity.payload.ReviewTransferPayload -import org.mifos.mobile.core.model.enums.TransferType -import org.mifos.mobile.core.ui.component.MifosErrorComponent -import org.mifos.mobile.core.ui.component.MifosProgressIndicator -import org.mifos.mobile.core.ui.utils.EventsEffect - -@Composable -internal fun ThirdPartyTransferScreen( - navigateBack: () -> Unit, - addBeneficiary: () -> Unit, - reviewTransfer: (ReviewTransferPayload, TransferType, TransferSuccessDestination) -> Unit, - modifier: Modifier = Modifier, - viewModel: ThirdPartyTransferViewModel = koinViewModel(), -) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - EventsEffect(viewModel.eventFlow) { event -> - when (event) { - ThirdPartyTransferEvent.AddBeneficiary -> addBeneficiary.invoke() - ThirdPartyTransferEvent.Navigate -> navigateBack.invoke() - is ThirdPartyTransferEvent.ReviewTransfer -> { - reviewTransfer( - event.reviewTransferPayload, - TransferType.TPT, - TransferSuccessDestination.HOME, - ) - } - } - } - - ThirdPartyTransferScreen( - state = state, - onAction = remember(viewModel) { - { viewModel.trySendAction(it) } - }, - modifier = modifier, - ) -} - -@Composable -private fun ThirdPartyTransferDialog( - state: ThirdPartyTransferState, -) { - when (state.dialogState) { - is ThirdPartyTransferState.DialogState.Error -> MifosErrorComponent( - message = state.dialogState.message, - isNetworkConnected = state.isOnline, - ) - - is ThirdPartyTransferState.DialogState.Loading -> MifosProgressIndicator(modifier = Modifier.fillMaxSize()) - - null -> Unit - } -} - -@Composable -private fun ThirdPartyTransferScreen( - state: ThirdPartyTransferState, - onAction: (ThirdPartyTransferAction) -> Unit, - modifier: Modifier = Modifier, -) { - MifosScaffold( - topBarTitle = stringResource(Res.string.third_party_transfer), - onNavigationIconClick = { onAction(ThirdPartyTransferAction.OnNavigate) }, - modifier = Modifier, - ) { - Box(modifier = Modifier) { - if (!state.beneficiaries.isNullOrEmpty() && !state.fromAccountDetail.isNullOrEmpty - () && !state.toAccountOption.isNullOrEmpty() - ) { - ThirdPartyTransferContent( - state = state, - onAction = onAction, - modifier = modifier, - ) - } - } - } - ThirdPartyTransferDialog( - state = state, - ) -} - -@Preview -@Composable -private fun ThirdPartyTransferScreenPreview() { - MifosMobileTheme { - ThirdPartyTransferScreen( - state = ThirdPartyTransferState(dialogState = null), - onAction = { }, - modifier = Modifier, - ) - } -} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferViewModel.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferViewModel.kt deleted file mode 100644 index 8b671984d7..0000000000 --- a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/ThirdPartyTransferViewModel.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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-mobile/blob/master/LICENSE.md - */ -package org.mifos.mobile.feature.third.party.transfer - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import mifos_mobile.feature.third_party_transfer.generated.resources.Res -import mifos_mobile.feature.third_party_transfer.generated.resources.internet_not_connected -import org.jetbrains.compose.resources.getString -import org.mifos.mobile.core.common.DataState -import org.mifos.mobile.core.data.repository.BeneficiaryRepository -import org.mifos.mobile.core.data.repository.ThirdPartyTransferRepository -import org.mifos.mobile.core.data.util.NetworkMonitor -import org.mifos.mobile.core.model.IgnoredOnParcel -import org.mifos.mobile.core.model.Parcelable -import org.mifos.mobile.core.model.Parcelize -import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary -import org.mifos.mobile.core.model.entity.payload.ReviewTransferPayload -import org.mifos.mobile.core.model.entity.templates.account.AccountOption -import org.mifos.mobile.core.model.entity.templates.account.AccountOptionsTemplate -import org.mifos.mobile.core.model.enums.TransferType -import org.mifos.mobile.core.ui.utils.BaseViewModel - -internal class ThirdPartyTransferViewModel( - private val transferRepository: ThirdPartyTransferRepository, - private val beneficiaryRepository: BeneficiaryRepository, - private val networkMonitor: NetworkMonitor, -) : BaseViewModel( - initialState = ThirdPartyTransferState(dialogState = null), -) { - - init { - viewModelScope.launch { - fetchAndUpdateTemplateState() - val message = getString(Res.string.internet_not_connected) - networkMonitor.isOnline.collect { isConnected -> - updateState { it.copy(isOnline = isConnected) } - if (!isConnected) { - updateState { - it.copy( - dialogState = ThirdPartyTransferState.DialogState.Error(message), - ) - } - } - } - } - } - - private fun updateState(update: (ThirdPartyTransferState) -> ThirdPartyTransferState) { - mutableStateFlow.update(update) - } - - override fun handleAction(action: ThirdPartyTransferAction) { - when (action) { - ThirdPartyTransferAction.OnAddBeneficiary -> sendEvent(ThirdPartyTransferEvent.AddBeneficiary) - ThirdPartyTransferAction.OnNavigate -> sendEvent(ThirdPartyTransferEvent.Navigate) - is ThirdPartyTransferAction.OnReviewTransfer -> { - sendEvent( - ThirdPartyTransferEvent.ReviewTransfer( - action.reviewTransferPayload, - action.transferType, - ), - ) - } - } - } - - private suspend fun fetchAndUpdateTemplateState() { - combine( - transferRepository.thirdPartyTransferTemplate(), - beneficiaryRepository.beneficiaryList(), - ) { templateResult, beneficiariesResult -> - updateStateFromResults(templateResult, beneficiariesResult) - }.catch { error -> - updateState { - it.copy(dialogState = ThirdPartyTransferState.DialogState.Error(error.message ?: "An error occurred")) - } - }.collect { } - } - - private fun updateStateFromResults( - templateResult: DataState, - beneficiariesResult: DataState>, - ) { - when { - templateResult is DataState.Loading || beneficiariesResult is DataState.Loading -> { - updateState { it.copy(dialogState = ThirdPartyTransferState.DialogState.Loading) } - } - templateResult is DataState.Error || beneficiariesResult is DataState.Error -> { - val error = (templateResult as? DataState.Error)?.exception?.message - ?: (beneficiariesResult as? DataState.Error)?.exception?.message - ?: "An error occurred" - val errorMessage = "An error occurred" - updateState { it.copy(dialogState = ThirdPartyTransferState.DialogState.Error(errorMessage)) } - } - templateResult is DataState.Success && beneficiariesResult is DataState.Success -> { - updateState { - it.copy( - fromAccountDetail = templateResult.data.fromAccountOptions - .filter { savingsAccount -> - savingsAccount.accountType?.value == "Savings Account" - }, - toAccountOption = templateResult.data.toAccountOptions, - beneficiaries = beneficiariesResult.data, - dialogState = null, - ) - } - } - } - } -} - -@Parcelize -data class ThirdPartyTransferState( - val isOnline: Boolean = false, - @IgnoredOnParcel - val fromAccountDetail: List? = listOf(), - @IgnoredOnParcel - val toAccountOption: List? = listOf(), - @IgnoredOnParcel - val beneficiaries: List? = listOf(), - val dialogState: DialogState? = null, -) : Parcelable { - - sealed interface DialogState : Parcelable { - @Parcelize - data class Error(val message: String) : DialogState - - @Parcelize - data object Loading : DialogState - } -} - -sealed interface ThirdPartyTransferEvent { - data object Navigate : ThirdPartyTransferEvent - data object AddBeneficiary : ThirdPartyTransferEvent - data class ReviewTransfer( - val reviewTransferPayload: ReviewTransferPayload, - val transferType: TransferType, - ) : ThirdPartyTransferEvent -} - -sealed interface ThirdPartyTransferAction { - data object OnNavigate : ThirdPartyTransferAction - data object OnAddBeneficiary : ThirdPartyTransferAction - data class OnReviewTransfer( - val reviewTransferPayload: ReviewTransferPayload, - val transferType: TransferType, - ) : ThirdPartyTransferAction -} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/di/ThirdPartyTransferModule.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/di/ThirdPartyTransferModule.kt index 1d82e1e0b8..e613131d2e 100644 --- a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/di/ThirdPartyTransferModule.kt +++ b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/di/ThirdPartyTransferModule.kt @@ -11,8 +11,8 @@ package org.mifos.mobile.feature.third.party.transfer.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module -import org.mifos.mobile.feature.third.party.transfer.ThirdPartyTransferViewModel +import org.mifos.mobile.feature.third.party.transfer.thirdPartyTransfer.TptViewModel val ThirdPartyTransferModule = module { - viewModelOf(::ThirdPartyTransferViewModel) + viewModelOf(::TptViewModel) } diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferNavGraph.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferNavGraph.kt deleted file mode 100644 index 09df27499b..0000000000 --- a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferNavGraph.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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-mobile/blob/master/LICENSE.md - */ -package org.mifos.mobile.feature.third.party.transfer.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.compose.navigation -import org.mifos.mobile.core.model.entity.TransferSuccessDestination -import org.mifos.mobile.core.model.entity.payload.ReviewTransferPayload -import org.mifos.mobile.core.model.enums.TransferType -import org.mifos.mobile.feature.third.party.transfer.ThirdPartyTransferScreen - -fun NavController.navigateToThirdPartyTransfer() { - navigate(ThirdPartyTransferNavigation.ThirdPartyTransferBase.route) -} - -fun NavGraphBuilder.thirdPartyTransferNavGraph( - navigateBack: () -> Unit, - addBeneficiary: () -> Unit, - reviewTransfer: (ReviewTransferPayload, TransferType, TransferSuccessDestination) -> Unit, -) { - navigation( - startDestination = ThirdPartyTransferNavigation.ThirdPartyTransferScreen.route, - route = ThirdPartyTransferNavigation.ThirdPartyTransferBase.route, - ) { - thirdPartyTransferRoute( - navigateBack = navigateBack, - addBeneficiary = addBeneficiary, - reviewTransfer = reviewTransfer, - ) - } -} - -fun NavGraphBuilder.thirdPartyTransferRoute( - navigateBack: () -> Unit, - addBeneficiary: () -> Unit, - reviewTransfer: (ReviewTransferPayload, TransferType, TransferSuccessDestination) -> Unit, -) { - composable( - route = ThirdPartyTransferNavigation.ThirdPartyTransferScreen.route, - ) { - ThirdPartyTransferScreen( - navigateBack = navigateBack, - addBeneficiary = addBeneficiary, - reviewTransfer = reviewTransfer, - ) - } -} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferNavigation.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferNavigation.kt deleted file mode 100644 index 7d252b1e41..0000000000 --- a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferNavigation.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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-mobile/blob/master/LICENSE.md - */ -package org.mifos.mobile.feature.third.party.transfer.navigation - -const val THIRD_PARTY_TRANSFER_NAVIGATION_ROUTE_BASE = "third_party_transfer_base_route" -const val THIRD_PARTY_TRANSFER_SCREEN_ROUTE = "third_party_transfer_screen_route" - -sealed class ThirdPartyTransferNavigation(val route: String) { - data object ThirdPartyTransferBase : ThirdPartyTransferNavigation( - route = THIRD_PARTY_TRANSFER_NAVIGATION_ROUTE_BASE, - ) - - data object ThirdPartyTransferScreen : ThirdPartyTransferNavigation( - route = THIRD_PARTY_TRANSFER_SCREEN_ROUTE, - ) -} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferRoute.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferRoute.kt new file mode 100644 index 0000000000..2895e26af8 --- /dev/null +++ b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/navigation/ThirdPartyTransferRoute.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.feature.third.party.transfer.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.navigation +import kotlinx.serialization.Serializable +import org.mifos.mobile.core.model.entity.payload.ReviewTransferPayload +import org.mifos.mobile.feature.third.party.transfer.thirdPartyTransfer.TptScreenRoute +import org.mifos.mobile.feature.third.party.transfer.thirdPartyTransfer.tptScreenDestination + +sealed class TptNavigationDestination { + // Add more as needed + object Notification : TptNavigationDestination() + class TransferProcess(val payload: ReviewTransferPayload) : TptNavigationDestination() + object AddBeneficiaryScreen : TptNavigationDestination() +} + +typealias TptNavigator = (TptNavigationDestination) -> Unit + +@Serializable +data object ThirdPartyTransferNavGraphRoute + +fun NavController.navigateToTptGraph(navOptions: NavOptions? = null) { + this.navigate( + ThirdPartyTransferNavGraphRoute, + navOptions = navOptions, + ) +} + +fun NavGraphBuilder.tptGraphDestination( + onNavigate: TptNavigator, +) { + navigation( + startDestination = TptScreenRoute, + ) { + tptScreenDestination( + onNavigate = onNavigate, + ) + } +} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptScreen.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptScreen.kt new file mode 100644 index 0000000000..806230fbde --- /dev/null +++ b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptScreen.kt @@ -0,0 +1,307 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.feature.third.party.transfer.thirdPartyTransfer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mifos_mobile.core.ui.generated.resources.ic_icon_logo_1 +import mifos_mobile.feature.third_party_transfer.generated.resources.Res +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_error_server +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_label_amount +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_label_destination +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_label_origin_account +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_label_remarks +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_tip +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_tip_action +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_transfer_button +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifos.mobile.core.designsystem.component.BasicDialogState +import org.mifos.mobile.core.designsystem.component.MifosBasicDialog +import org.mifos.mobile.core.designsystem.component.MifosButton +import org.mifos.mobile.core.designsystem.component.MifosElevatedScaffold +import org.mifos.mobile.core.designsystem.component.MifosOutlinedTextField +import org.mifos.mobile.core.designsystem.component.MifosTextFieldConfig +import org.mifos.mobile.core.designsystem.icon.MifosIcons +import org.mifos.mobile.core.designsystem.theme.DesignToken +import org.mifos.mobile.core.designsystem.theme.MifosTypography +import org.mifos.mobile.core.ui.component.MifosDropDownDoubleTextField +import org.mifos.mobile.core.ui.component.MifosErrorComponent +import org.mifos.mobile.core.ui.component.MifosPayFromDropdownUI +import org.mifos.mobile.core.ui.component.MifosProgressIndicator +import org.mifos.mobile.core.ui.component.MifosProgressIndicatorOverlay +import org.mifos.mobile.core.ui.utils.EventsEffect +import org.mifos.mobile.feature.third.party.transfer.navigation.TptNavigationDestination +import org.mifos.mobile.feature.third.party.transfer.navigation.TptNavigator + +@Composable +internal fun TptScreen( + onNavigate: TptNavigator, + viewModel: TptViewModel = koinViewModel(), +) { + val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel.eventFlow) { event -> + when (event) { + is TptEvent.NavigateToTransferScreen -> { + onNavigate(TptNavigationDestination.TransferProcess(event.reviewTransferPayload)) + } + + is TptEvent.NavigateToNotificationScreen -> { + onNavigate(TptNavigationDestination.Notification) + } + + is TptEvent.NavigateToAddBeneficiaryScreen -> { + onNavigate(TptNavigationDestination.AddBeneficiaryScreen) + } + } + } + + TprContent( + state = uiState, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) + + TptDialog( + dialogState = uiState.dialogState, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) +} + +@Composable +internal fun TptDialog( + dialogState: TptState.DialogState?, + onAction: (TptAction) -> Unit, +) { + when (dialogState) { + is TptState.DialogState.Error -> { + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + message = stringResource(dialogState.message), + ), + onDismissRequest = { onAction(TptAction.DismissDialog) }, + ) + } + + null -> {} + } +} + +@Composable +internal fun TprContent( + state: TptState, + onAction: (TptAction) -> Unit, + modifier: Modifier = Modifier, +) { + MifosElevatedScaffold( + modifier = modifier, + brandIcon = mifos_mobile.core.ui.generated.resources.Res.drawable.ic_icon_logo_1, + topBarTitle = "Home", + onNavigateBack = { }, + actions = { + Row( + horizontalArrangement = Arrangement.spacedBy(DesignToken.spacing.large), + ) { + // TODO : once ui/ux team gives this flow uncomment and implement +// Image( +// imageVector = MifosIcons.SearchNew, +// contentDescription = null, +// ) + Image( + imageVector = MifosIcons.Alert, + contentDescription = null, + modifier = Modifier.clickable { + onAction(TptAction.OnNotificationClicked) + }, + ) + } + }, + ) { + when (state.uiState) { + TptState.TptScreenState.Loading -> MifosProgressIndicator() + + TptState.TptScreenState.OverlayLoading -> MifosProgressIndicatorOverlay() + + is TptState.TptScreenState.Error -> { + MifosErrorComponent( + message = stringResource(Res.string.feature_tpt_error_server), + isRetryEnabled = true, + onRetry = { onAction(TptAction.OnRetry) }, + ) + } + + TptState.TptScreenState.Network -> { + MifosErrorComponent( + isNetworkConnected = state.networkStatus, + isRetryEnabled = true, + onRetry = { onAction(TptAction.OnRetry) }, + ) + } + + TptState.TptScreenState.Success -> { + TptForm(state, onAction) + } + + null -> { } + } + } +} + +@Composable +internal fun TptForm( + state: TptState, + onAction: (TptAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(DesignToken.padding.large) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(DesignToken.padding.largeIncreased), + ) { + MifosPayFromDropdownUI( + accounts = state.fromAccountOptions.map + { Pair(it.accountNo ?: "", it.clientName ?: "") }, + onAccountSelected = { account, balance -> + onAction(TptAction.OnFromAccountSelected(account)) + }, + label = stringResource(Res.string.feature_tpt_label_origin_account), + ) + + MifosDropDownDoubleTextField( + optionsList = state.toAccountOptions.map + { Pair(it.accountNo ?: "", it.clientName ?: "") }, + selectedOption = state.toAccount?.accountNo ?: "", + isEnabled = true, + labelResId = Res.string.feature_tpt_label_destination, + onClick = { index, _ -> + onAction( + TptAction.OnToAccountSelected( + state.toAccountOptions[index].accountNo ?: "", + ), + ) + }, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(DesignToken.spacing.small), + modifier = Modifier + .align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(Res.string.feature_tpt_tip), + style = MifosTypography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) + + Text( + modifier = Modifier + .clickable { + onAction(TptAction.OnAddBeneficiaryClicked) + }, + text = stringResource(Res.string.feature_tpt_tip_action), + style = MifosTypography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + MifosOutlinedTextField( + value = state.amount, + onValueChange = { onAction(TptAction.OnAmountChanged(it)) }, + label = stringResource(Res.string.feature_tpt_label_amount), + shape = DesignToken.shapes.medium, + textStyle = MifosTypography.bodyLarge, + config = MifosTextFieldConfig( + isError = state.amountError != null, + errorText = state.amountError?.let { + stringResource(it) + }, + trailingIcon = if (state.amountError != null) { + { + Icon( + imageVector = MifosIcons.ErrorCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + null + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + ), + ) + + MifosOutlinedTextField( + value = state.remark, + onValueChange = { onAction(TptAction.OnRemarksChanged(it)) }, + label = stringResource(Res.string.feature_tpt_label_remarks), + shape = DesignToken.shapes.medium, + textStyle = MifosTypography.bodyLarge, + config = MifosTextFieldConfig( + isError = state.remarkError != null, + errorText = state.remarkError?.let { + stringResource(it) + }, + trailingIcon = if (state.remarkError != null) { + { + Icon( + imageVector = MifosIcons.ErrorCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + null + }, + ), + ) + + MifosButton( + modifier = Modifier + .fillMaxWidth() + .height(DesignToken.sizes.buttonHeight), + onClick = { + onAction(TptAction.OnMakeTransferClicked) + }, + enabled = state.isEnabled, + shape = DesignToken.shapes.medium, + ) { + Text( + text = stringResource(Res.string.feature_tpt_transfer_button), + style = MifosTypography.titleMedium, + ) + } + } +} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptScreenRoute.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptScreenRoute.kt new file mode 100644 index 0000000000..d4a27b5ed2 --- /dev/null +++ b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptScreenRoute.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.feature.third.party.transfer.thirdPartyTransfer + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import kotlinx.serialization.Serializable +import org.mifos.mobile.core.ui.composableWithSlideTransitions +import org.mifos.mobile.feature.third.party.transfer.navigation.TptNavigator + +@Serializable +data object TptScreenRoute + +fun NavController.navigateToTptScreen(navOptions: NavOptions? = null) { + this.navigate(TptScreenRoute, navOptions) +} + +fun NavGraphBuilder.tptScreenDestination( + onNavigate: TptNavigator, +) { + composableWithSlideTransitions { + TptScreen( + onNavigate = onNavigate, + ) + } +} diff --git a/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptViewModel.kt b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptViewModel.kt new file mode 100644 index 0000000000..95595d6ff4 --- /dev/null +++ b/feature/third-party-transfer/src/commonMain/kotlin/org/mifos/mobile/feature/third/party/transfer/thirdPartyTransfer/TptViewModel.kt @@ -0,0 +1,612 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.feature.third.party.transfer.thirdPartyTransfer + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mifos_mobile.feature.third_party_transfer.generated.resources.Res +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_error_amount_invalid +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_error_amount_required +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_error_remarks_empty +import mifos_mobile.feature.third_party_transfer.generated.resources.feature_tpt_error_remarks_invalid +import org.jetbrains.compose.resources.StringResource +import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.data.repository.ThirdPartyTransferRepository +import org.mifos.mobile.core.data.util.NetworkMonitor +import org.mifos.mobile.core.datastore.UserPreferencesRepository +import org.mifos.mobile.core.model.entity.payload.ReviewTransferPayload +import org.mifos.mobile.core.model.entity.templates.account.AccountOption +import org.mifos.mobile.core.model.entity.templates.account.AccountOptionsTemplate +import org.mifos.mobile.core.model.enums.AccountType +import org.mifos.mobile.core.ui.utils.BaseViewModel +import org.mifos.mobile.core.ui.utils.ValidationHelper +/** + * ViewModel for the Make Transfer screen. + * + * This ViewModel handles the business logic for making a transfer, including fetching + * account options, validating user input, and initiating the transfer process. + * + * @param thirdPartyTransferRepositoryImpl The repository for third-party transfer operations. + * @param networkMonitor A utility to monitor network connectivity. + * @param userPreferencesRepositoryImpl The repository for accessing user preferences, like client ID. + */ +internal class TptViewModel( + private val thirdPartyTransferRepositoryImpl: ThirdPartyTransferRepository, + private val networkMonitor: NetworkMonitor, + private val userPreferencesRepositoryImpl: UserPreferencesRepository, +) : BaseViewModel( + initialState = run { + TptState( + clientId = requireNotNull(userPreferencesRepositoryImpl.clientId.value), + uiState = TptState.TptScreenState.Loading, + ) + }, +) { + + init { + observeNetworkStatus() + } + + private var validationJob: Job? = null + + /* + * Functions related to UI State and Dialogs + */ + + /** + * A helper function to update the mutable state flow. + * + * @param update A lambda function that takes the current state and returns a new state. + */ + private fun updateState(update: (TptState) -> TptState) { + mutableStateFlow.update(update) + } + + /** + * Dismisses any currently shown dialog by setting the dialog state to null. + */ + private fun dismissDialog() { + updateState { it.copy(dialogState = null) } + } + + /** + * Sets the UI state to an overlay loading spinner. + */ + private fun showOverlayLoading() { + updateState { it.copy(uiState = TptState.TptScreenState.OverlayLoading) } + } + + /** + * Sets the UI state to a full-screen loading spinner. + */ + private fun showLoading() { + updateState { it.copy(uiState = TptState.TptScreenState.Loading) } + } + + /** + * Displays an error dialog with a given message. + * + * @param error The [StringResource] for the error message to display. + */ + @Suppress("UnusedPrivateMember") + private fun showErrorDialog(error: StringResource) { + updateState { it.copy(dialogState = TptState.DialogState.Error(error)) } + } + + /** + * Handles incoming actions from the UI. + * + * @param action The [TptAction] dispatched from the UI. + */ + override fun handleAction(action: TptAction) { + when (action) { + is TptAction.OnToAccountSelected -> handleToAccountChange(action.accountNo) + + is TptAction.OnFromAccountSelected -> handleFromAccountChange(action.accountNo) + + is TptAction.OnAmountChanged -> handleAmountChange(action.amount) + + is TptAction.OnRemarksChanged -> handleRemarkChange(action.remarks) + + TptAction.OnMakeTransferClicked -> validateAndPerformTransfer() + + TptAction.OnNotificationClicked -> sendEvent( + TptEvent.NavigateToNotificationScreen, + ) + + TptAction.OnAddBeneficiaryClicked -> sendEvent( + TptEvent.NavigateToAddBeneficiaryScreen, + ) + + TptAction.DismissDialog -> dismissDialog() + + is TptAction.Internal.PerformTransfer -> performTransfer() + + is TptAction.Internal.ReceiveTransferTemplateResult -> + handleTransferTemplateResult(action.dataState) + + TptAction.OnRetry -> retry() + } + } + + /** + * Retries the last failed operation. + * + * Checks network status and either shows a network error or re-fetches the accounts. + */ + private fun retry() { + viewModelScope.launch { + if (!state.networkStatus) { + updateState { it.copy(uiState = TptState.TptScreenState.Network) } + } else { + fetchAccountOptions() + } + } + } + + /** + * Handles the selection of a "from" (origin) account. + * + * This function updates the state by: + * 1. Finding the selected account from the full list of 'from' accounts. + * 2. Filtering the list of 'to' (destination) accounts to exclude the selected 'from' account. + * + * @param fromAccount The account number of the selected 'from' account. + */ + private fun handleFromAccountChange(fromAccount: String) { + val fromAccountSelected = state.accountOptionsTemplate.fromAccountOptions + .find { it.accountNo == fromAccount } + + val toAccounts = state.accountOptionsTemplate.toAccountOptions.filter { + it.accountNo != fromAccount + } + updateState { + it.copy( + fromAccount = fromAccountSelected, + toAccountOptions = toAccounts, + ) + } + } + + /** + * Handles the selection of a "to" (destination) account. + * + * This function updates the state by: + * 1. Finding the selected account from the full list of 'to' accounts. + * 2. Filtering the list of 'from' (origin) accounts to exclude the selected 'to' account + * and ensure only savings accounts are included. + * + * @param toAccount The account number of the selected 'to' account. + */ + private fun handleToAccountChange(toAccount: String) { + val toAccountSelected = state.accountOptionsTemplate.toAccountOptions + .find { it.accountNo == toAccount } + + val fromAccounts = state.accountOptionsTemplate.fromAccountOptions.filter { + it.accountNo != toAccount && it.accountType?.value == AccountType.SAVINGS.value + } + + updateState { + it.copy( + toAccount = toAccountSelected, + fromAccountOptions = fromAccounts, + ) + } + } + + /** + * Handles changes to the transfer amount input field. + * + * This function updates the state with the new amount and triggers a debounced validation + * to prevent validation on every keystroke. + * + * @param amount The new string value of the amount. + */ + private fun handleAmountChange(amount: String) { + updateState { + it.copy( + amount = amount, + amountError = null, + ) + } + + debounceValidation { + val result = validateAmount(amount) + mutableStateFlow.update { + it.copy( + amountError = if (result is ValidationResult.Error) { + result.message + } else { + null + }, + ) + } + } + } + + /** + * Handles changes to the remarks input field. + * + * This function updates the state with the new remark and triggers a debounced validation. + * + * @param remark The new string value of the remark. + */ + private fun handleRemarkChange(remark: String) { + updateState { + it.copy( + remark = remark, + remarkError = null, + ) + } + + debounceValidation { + val result = validateRemark(remark) + mutableStateFlow.update { + it.copy( + remarkError = if (result is ValidationResult.Error) { + result.message + } else { + null + }, + ) + } + } + } + + /** + * Validates all form fields and, if valid, proceeds to perform the transfer. + * + * It checks for validation errors in the amount and remarks fields. If valid, + * it dispatches an internal action to perform the transfer. + */ + private fun validateAndPerformTransfer() { + val amountResult = validateAmount(state.amount) + val remarkResult = validateRemark(state.remark) + + mutableStateFlow.update { + it.copy( + amountError = if (amountResult is ValidationResult.Error) amountResult.message else null, + remarkError = if (remarkResult is ValidationResult.Error) remarkResult.message else null, + ) + } + + val isValid = listOf( + amountResult, + remarkResult, + ).all { it is ValidationResult.Success } + if (isValid) { + viewModelScope.launch { + sendAction(TptAction.Internal.PerformTransfer) + } + } + } + + /** + * Initiates the transfer by creating a [ReviewTransferPayload] and sending a + * navigation event to the UI. + */ + private fun performTransfer() { + val payload = ReviewTransferPayload( + payToAccount = state.toAccount, + payFromAccount = state.fromAccount, + amount = state.amount, + review = state.remark, + ) + sendEvent( + TptEvent.NavigateToTransferScreen( + reviewTransferPayload = payload, + ), + ) + } + + /** + * Validates the transfer amount. + * + * @param amount The string amount to validate. + * @return A [ValidationResult] indicating success or an error. + */ + private fun validateAmount(amount: String) = when { + amount.isBlank() -> ValidationResult.Error(Res.string.feature_tpt_error_amount_required) + amount.toIntOrNull() == null -> ValidationResult.Error(Res.string.feature_tpt_error_amount_invalid) + else -> ValidationResult.Success + } + + /** + * Validates the remarks field. + * + * @param remark The string remark to validate. + * @return A [ValidationResult] indicating success or an error. + */ + private fun validateRemark(remark: String): ValidationResult = + when { + remark.isEmpty() -> + ValidationResult.Error(Res.string.feature_tpt_error_remarks_empty) + + !ValidationHelper.isValidName(remark) -> + ValidationResult.Error(Res.string.feature_tpt_error_remarks_invalid) + + else -> ValidationResult.Success + } + + /** + * Observes the network status and updates the UI state accordingly. + * + * When the network is online, it triggers a data fetch. If the network is offline, + * it sets the UI state to a network error. + */ + private fun observeNetworkStatus() { + viewModelScope.launch { + networkMonitor.isOnline + .distinctUntilChanged() + .collect { isOnline -> + updateState { + it.copy( + networkStatus = isOnline, + uiState = if (!isOnline) { + TptState.TptScreenState.Network + } else { + null + }, + ) + } + if (isOnline) { + fetchAccountOptions() + } + } + } + } + + /** + * Fetches the account transfer template which includes options for 'from' and 'to' accounts. + * This function first shows a loading state and then calls the repository to get the data. + */ + private fun fetchAccountOptions() { + showLoading() + viewModelScope.launch { + thirdPartyTransferRepositoryImpl + .thirdPartyTransferTemplate() + .collect { result -> + sendAction( + TptAction.Internal.ReceiveTransferTemplateResult(result), + ) + } + } + } + + /** + * Handles the result of fetching the account transfer template. + * + * Updates the UI state based on success, error, or loading states. If successful, it populates + * the `fromAccountOptions` with savings accounts and the `toAccountOptions` with the full list. + * + * @param dataState The [DataState] of the [AccountOptionsTemplate] fetch operation. + */ + private fun handleTransferTemplateResult(dataState: DataState) { + when (dataState) { + is DataState.Error -> { + updateState { + it.copy( + uiState = TptState.TptScreenState.Error( + dataState.message, + ), + ) + } + } + + DataState.Loading -> { + showOverlayLoading() + } + + is DataState.Success -> { + val savingsAccounts = dataState.data.fromAccountOptions.filter { acc -> + acc.accountType?.value == AccountType.SAVINGS.value + } + + updateState { + it.copy( + accountOptionsTemplate = dataState.data, + fromAccountOptions = savingsAccounts, + toAccountOptions = dataState.data.toAccountOptions, + uiState = TptState.TptScreenState.Success, + ) + } + } + } + } + + /** + * Cancels any ongoing validation and launches the given validation block after a delay. + * Used for debounced validation of form fields to prevent unnecessary updates on every keystroke. + */ + private fun debounceValidation(validation: suspend () -> Unit) { + validationJob?.cancel() + validationJob = viewModelScope.launch { + delay(300) + validation() + } + } +} + +/** + * Represents the state of the Make Transfer screen. + * + * @property clientId The ID of the current user. + * @property outstandingBalance The outstanding balance of the primary account, if applicable. + * @property amount The amount entered by the user for the transfer. + * @property amountError The error message for the amount field, if any. + * @property remark Optional remarks or notes for the transfer. + * @property remarkError The error message for the remark field, if any. + * @property accountOptionsTemplate The full template containing all 'from' and 'to' accounts. + * @property fromAccountOptions List of accounts available to transfer from. + * @property toAccountOptions List of accounts available to transfer to. + * @property fromAccount The currently selected account to transfer from. + * @property toAccount The currently selected account to transfer to. + * @property dialogState The current state of any dialogs to be shown (e.g., loading, error). + * @property networkStatus The current network connectivity status. + * @property uiState The overall screen state (Loading, Success, Error, etc.). + */ +internal data class TptState( + val accountId: Long = -1L, + val clientId: Long = -1L, + val outstandingBalance: Double? = null, + val amount: String = "", + val amountError: StringResource? = null, + val remark: String = "", + val remarkError: StringResource? = null, + var accountOptionsTemplate: AccountOptionsTemplate = AccountOptionsTemplate(), + var fromAccountOptions: List = emptyList(), + var toAccountOptions: List = emptyList(), + val fromAccount: AccountOption? = null, + val toAccount: AccountOption? = null, + val dialogState: DialogState? = null, + val networkStatus: Boolean = false, + val uiState: TptScreenState?, +) { + /** + * Represents the possible states of a dialog shown on the Make Transfer screen. + */ + sealed interface DialogState { + /** + * Represents an error state, containing an error message. + * @property message The error message to display. + */ + data class Error(val message: StringResource) : DialogState + } + + /** + * Represents the possible overall states of the screen. + */ + sealed interface TptScreenState { + /** Represents a full-screen loading state. */ + data object Loading : TptScreenState + + /** + * Represents an error state with a message. + * @property message The string message for the error. + */ + data class Error(val message: String) : TptScreenState + + /** Represents a successful state where content can be displayed. */ + data object Success : TptScreenState + + /** Represents a state where there is a network connectivity issue. */ + data object Network : TptScreenState + + /** Represents a state where an overlay loading spinner should be shown. */ + data object OverlayLoading : TptScreenState + } + + /** + * A computed property that determines if the make transfer button should be enabled. + * It requires a valid 'from' account, a valid 'to' account, a non-blank amount and remark, + * and no validation errors. + */ + val isEnabled: Boolean = fromAccount != null && + toAccount != null && + amount.isNotBlank() && + remark.isNotBlank() && + amountError == null && + remarkError == null +} + +/** + * Represents user actions that can occur on the Make Transfer screen. + * These actions are dispatched from the UI to the [TptViewModel]. + */ +internal sealed interface TptAction { + /** Action triggered when a 'to' account is selected. @property accountNo The account number selected. */ + data class OnToAccountSelected(val accountNo: String) : TptAction + + /** Action triggered when a 'from' account is selected. @property accountNo The account number selected. */ + data class OnFromAccountSelected(val accountNo: String) : TptAction + + /** Action triggered when the transfer amount is changed. @property amount The new amount string. */ + data class OnAmountChanged(val amount: String) : TptAction + + /** Action triggered when the remarks are changed. @property remarks The new remarks string. */ + data class OnRemarksChanged(val remarks: String) : TptAction + + /** Action triggered when the 'Make Transfer' button is clicked. */ + data object OnMakeTransferClicked : TptAction + + /** Action triggered when the 'Notification' Icon clicked. */ + data object OnNotificationClicked : TptAction + + /** Action triggered when the 'Add Beneficiary' clicked. */ + data object OnAddBeneficiaryClicked : TptAction + + /** Action triggered to dismiss any currently shown dialog. */ + data object DismissDialog : TptAction + + /** Action triggered to retry a failed operation, typically fetching account options. */ + data object OnRetry : TptAction + + /** + * Represents internal actions used within the ViewModel, not directly triggered by the UI. + */ + sealed interface Internal : TptAction { + /** Internal action to initiate the transfer process. */ + data object PerformTransfer : Internal + + /** + * Internal action representing the result of fetching account options. + * @property dataState The result of the fetch operation. + */ + data class ReceiveTransferTemplateResult(val dataState: DataState) : Internal + } +} + +/** + * Represents events that the [TptViewModel] can send to the UI. + * These events typically trigger navigation or one-time UI updates. + */ +internal sealed interface TptEvent { + /** + * Event to navigate to the transfer review screen. + * + * @property reviewTransferPayload The payload containing details for the transfer review. + */ + data class NavigateToTransferScreen( + val reviewTransferPayload: ReviewTransferPayload, + ) : TptEvent + + /** + * Event to navigate to the Notification screen. + * + */ + data object NavigateToNotificationScreen : TptEvent + + /** + * Event to navigate to the Add Beneficiary screen. + * + */ + data object NavigateToAddBeneficiaryScreen : TptEvent +} + +/** + * Represents the result of a field validation operation. + * + * This sealed class provides a structured way to return the outcome of a validation check, + * indicating either success or a specific error. + */ +internal sealed class ValidationResult { + /** Indicates that the validation passed successfully. */ + data object Success : ValidationResult() + + /** + * Indicates that the validation failed. + * @property message The localized string resource for the error message. + */ + data class Error(val message: StringResource) : ValidationResult() +} diff --git a/feature/transfer-process/src/commonMain/kotlin/org/mifos/mobile/feature/transfer/process/transferProcess/TransferProcessScreen.kt b/feature/transfer-process/src/commonMain/kotlin/org/mifos/mobile/feature/transfer/process/transferProcess/TransferProcessScreen.kt index d9c67c9b58..9302751b9e 100644 --- a/feature/transfer-process/src/commonMain/kotlin/org/mifos/mobile/feature/transfer/process/transferProcess/TransferProcessScreen.kt +++ b/feature/transfer-process/src/commonMain/kotlin/org/mifos/mobile/feature/transfer/process/transferProcess/TransferProcessScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,6 +40,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel import org.mifos.mobile.core.designsystem.component.MifosButton import org.mifos.mobile.core.designsystem.component.MifosElevatedScaffold +import org.mifos.mobile.core.designsystem.component.MifosOutlinedButton import org.mifos.mobile.core.designsystem.theme.DesignToken import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme import org.mifos.mobile.core.model.enums.TransferType @@ -159,13 +162,24 @@ private fun TransferProcessContent( ), ) - MifosButton( + MifosOutlinedButton( modifier = Modifier .fillMaxWidth() .height(DesignToken.sizes.buttonHeight), - text = { Text(text = stringResource(Res.string.cancel)) }, - onClick = { onAction(TransferProcessAction.OnNavigate) }, - ) + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.onPrimary, + contentColor = MaterialTheme.colorScheme.primary, + ), + onClick = { + onAction(TransferProcessAction.OnNavigate) + }, + shape = DesignToken.shapes.medium, + ) { + Text( + text = stringResource(Res.string.cancel), + style = MaterialTheme.typography.labelLarge, + ) + } MifosButton( modifier = Modifier