diff --git a/cmp-android/src/main/AndroidManifest.xml b/cmp-android/src/main/AndroidManifest.xml index 5e8f3f55..f773d561 100644 --- a/cmp-android/src/main/AndroidManifest.xml +++ b/cmp-android/src/main/AndroidManifest.xml @@ -45,7 +45,7 @@ android:name=".MainActivity" android:exported="true" android:theme="@style/Theme.MifosSplash" - android:windowSoftInputMode="adjustPan|adjustResize"> + android:windowSoftInputMode="adjustResize"> 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 9240c2b8..2ec32d2e 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 @@ -15,6 +15,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import org.mifospay.core.ui.utility.TabContent import org.mifospay.feature.accounts.AccountsScreen +import org.mifospay.feature.accounts.beneficiary.BeneficiaryAddEditType import org.mifospay.feature.accounts.beneficiary.addEditBeneficiaryScreen import org.mifospay.feature.accounts.beneficiary.navigateToBeneficiaryAddEdit import org.mifospay.feature.accounts.savingsaccount.SavingsAddEditType @@ -47,6 +48,8 @@ import org.mifospay.feature.make.transfer.navigation.navigateToTransferScreen import org.mifospay.feature.make.transfer.navigation.transferScreen import org.mifospay.feature.make.transfer.success.navigateTransferSuccess import org.mifospay.feature.make.transfer.success.transferSuccessScreen +import org.mifospay.feature.make.transfer.v2.makeTransferScreenV2 +import org.mifospay.feature.make.transfer.v2.navigateToMakeTransferScreenV2 import org.mifospay.feature.merchants.navigation.merchantTransferScreen import org.mifospay.feature.notification.navigateToNotification import org.mifospay.feature.notification.notificationScreen @@ -65,6 +68,10 @@ import org.mifospay.feature.savedcards.details.cardDetailRoute import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen +import org.mifospay.feature.send.money.selectScreen.navigateToSelectAccountScreen +import org.mifospay.feature.send.money.selectScreen.selectAccountScreenDestination +import org.mifospay.feature.send.money.v2.navigateToSendMoneyV2Screen +import org.mifospay.feature.send.money.v2.sendMoneyScreenDestination import org.mifospay.feature.settings.navigation.settingsScreen import org.mifospay.feature.standing.instruction.createOrUpdate.addEditSIScreen import org.mifospay.feature.standing.instruction.details.siDetailsScreen @@ -150,7 +157,7 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyV2Screen, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, navigateToHistory = navController::navigateToHistory, @@ -277,6 +284,37 @@ internal fun MifosNavHost( navigateToScanQrScreen = navController::navigateToScanQr, ) + selectAccountScreenDestination( + navigateToMakeTransferV2Screen = navController::navigateToMakeTransferScreenV2, + navigateBack = navController::popBackStack, + ) + + makeTransferScreenV2( + navigateBack = navController::popBackStack, + onTransferSuccess = { + navController.navigateTransferSuccess( + navOptions { + popUpTo(SEND_MONEY_BASE_ROUTE) { + inclusive = true + } + launchSingleTop = true + }, + ) + }, + ) + + sendMoneyScreenDestination( + navigateToSelectAccountScreen = { + navController.navigateToSelectAccountScreen() + }, + navigateToBeneficiary = { + navController.navigateToBeneficiaryAddEdit( + BeneficiaryAddEditType.AddItem, + ) + }, + navigateBack = navController::popBackStack, + ) + transferScreen( navigateBack = navController::popBackStack, onTransferSuccess = { diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ClientRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ClientRepository.kt index 656ece77..7f46702e 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ClientRepository.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ClientRepository.kt @@ -32,7 +32,7 @@ interface ClientRepository { suspend fun updateClientImage(clientId: Long, image: String): DataState - suspend fun getClientAccounts(clientId: Long): Flow> + suspend fun getClientAccounts(clientId: Long): ClientAccountsEntity suspend fun getAccounts(clientId: Long, accountType: String): Flow>> diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ThirdPartyTransferRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ThirdPartyTransferRepository.kt index 80598f2e..a808d764 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ThirdPartyTransferRepository.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/ThirdPartyTransferRepository.kt @@ -9,14 +9,13 @@ */ package org.mifospay.core.data.repository -import kotlinx.coroutines.flow.Flow import org.mifospay.core.common.DataState import org.mifospay.core.network.model.entity.TPTResponse import org.mifospay.core.network.model.entity.payload.TransferPayload import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate interface ThirdPartyTransferRepository { - suspend fun getTransferTemplate(): Flow> + suspend fun getTransferTemplate(): AccountOptionsTemplate - suspend fun makeTransfer(payload: TransferPayload): Flow> + suspend fun makeTransfer(payload: TransferPayload): DataState } diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ClientRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ClientRepositoryImpl.kt index ca8e2b20..d805ac09 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ClientRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ClientRepositoryImpl.kt @@ -90,10 +90,9 @@ class ClientRepositoryImpl( } } - override suspend fun getClientAccounts(clientId: Long): Flow> { + override suspend fun getClientAccounts(clientId: Long): ClientAccountsEntity { return apiManager.clientsApi .getClientAccounts(clientId) - .asDataStateFlow().flowOn(ioDispatcher) } override suspend fun getAccounts( diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ThirdPartyTransferRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ThirdPartyTransferRepositoryImpl.kt index faaa389f..e0966aec 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ThirdPartyTransferRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/ThirdPartyTransferRepositoryImpl.kt @@ -10,29 +10,29 @@ package org.mifospay.core.data.repositoryImpl import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOn import org.mifospay.core.common.DataState -import org.mifospay.core.common.asDataStateFlow import org.mifospay.core.data.repository.ThirdPartyTransferRepository -import org.mifospay.core.network.FineractApiManager +import org.mifospay.core.network.SelfServiceApiManager import org.mifospay.core.network.model.entity.TPTResponse import org.mifospay.core.network.model.entity.payload.TransferPayload import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate class ThirdPartyTransferRepositoryImpl( - private val apiManager: FineractApiManager, + private val apiManager: SelfServiceApiManager, private val ioDispatcher: CoroutineDispatcher, ) : ThirdPartyTransferRepository { - override suspend fun getTransferTemplate(): Flow> { + override suspend fun getTransferTemplate(): AccountOptionsTemplate { return apiManager.thirdPartyTransferApi .accountTransferTemplate() - .asDataStateFlow().flowOn(ioDispatcher) } - override suspend fun makeTransfer(payload: TransferPayload): Flow> { - return apiManager.thirdPartyTransferApi - .makeTransfer(payload) - .asDataStateFlow().flowOn(ioDispatcher) + override suspend fun makeTransfer(payload: TransferPayload): DataState { + return try { + val result = apiManager.thirdPartyTransferApi + .makeTransfer(payload) + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } } } diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/TextField.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/TextField.kt index c9354637..a1389026 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/TextField.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/TextField.kt @@ -124,6 +124,7 @@ fun MifosTextField( errorText: String? = null, onClickClearIcon: () -> Unit = { onValueChange("") }, visualTransformation: VisualTransformation = VisualTransformation.None, + textStyle: TextStyle? = null, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = true, maxLines: Int = if (singleLine) 1 else Int.Companion.MAX_VALUE, @@ -177,9 +178,9 @@ fun MifosTextField( ) } }, - textStyle = LocalDensity.current.run { - TextStyle(color = KptTheme.colorScheme.onSurface) - }, + textStyle = (textStyle ?: TextStyle()).copy( + color = KptTheme.colorScheme.onSurface, + ), ) } 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 5bca4690..abe0225c 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 @@ -25,10 +25,13 @@ import androidx.compose.material.icons.filled.ContentCopy 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 import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.PhotoLibrary @@ -78,6 +81,7 @@ object MifosIcons { val ChevronRight: ImageVector = Icons.Filled.ChevronRight val QrCode: ImageVector = Icons.Filled.QrCode val Close: ImageVector = Icons.Filled.Close + val Error: ImageVector = Icons.Filled.Error val AttachMoney: ImageVector = Icons.Filled.AttachMoney val OutlinedVisibilityOff: ImageVector = Icons.Outlined.VisibilityOff val OutlinedVisibility: ImageVector = Icons.Outlined.Visibility @@ -85,6 +89,7 @@ object MifosIcons { val Visibility: ImageVector = Icons.Filled.Visibility val Check: ImageVector = Icons.Default.Check val KeyboardArrowDown: ImageVector = Icons.Default.KeyboardArrowDown + val KeyboardArrowUp: ImageVector = Icons.Default.KeyboardArrowUp val Home = Icons.Outlined.Home val HomeBoarder = Icons.Rounded.Home val Payment = Icons.Rounded.SwapHoriz @@ -129,4 +134,5 @@ object MifosIcons { val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + val History = Icons.Default.History } diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/model/entity/templates/account/AccountOption.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/model/entity/templates/account/AccountOption.kt index 0741583b..38b9f89e 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/model/entity/templates/account/AccountOption.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/model/entity/templates/account/AccountOption.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.Serializable data class AccountOption( val accountId: Int? = null, val accountNo: String? = null, - val accountType: org.mifospay.core.network.model.entity.templates.account.AccountType? = null, + val accountType: AccountType? = null, val clientId: Long? = null, val clientName: String? = null, val officeId: Int? = null, diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ClientService.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ClientService.kt index b618063c..402ad678 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ClientService.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ClientService.kt @@ -53,7 +53,7 @@ interface ClientService { ): Unit @GET(ApiEndPoints.CLIENTS + "/{clientId}/accounts") - suspend fun getClientAccounts(@Path("clientId") clientId: Long): Flow + suspend fun getClientAccounts(@Path("clientId") clientId: Long): ClientAccountsEntity @GET(ApiEndPoints.CLIENTS + "/{clientId}/accounts") fun getAccounts( diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ThirdPartyTransferService.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ThirdPartyTransferService.kt index a9951061..73568a9b 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ThirdPartyTransferService.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/ThirdPartyTransferService.kt @@ -12,7 +12,6 @@ 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 kotlinx.coroutines.flow.Flow import org.mifospay.core.network.model.entity.TPTResponse import org.mifospay.core.network.model.entity.payload.TransferPayload import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate @@ -20,8 +19,8 @@ import org.mifospay.core.network.utils.ApiEndPoints interface ThirdPartyTransferService { @GET(ApiEndPoints.ACCOUNT_TRANSFER + "/template?type=tpt") - suspend fun accountTransferTemplate(): Flow + suspend fun accountTransferTemplate(): AccountOptionsTemplate @POST(ApiEndPoints.ACCOUNT_TRANSFER + "?type=tpt") - suspend fun makeTransfer(@Body transferPayload: TransferPayload): Flow + suspend fun makeTransfer(@Body transferPayload: TransferPayload): TPTResponse } diff --git a/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/MifosSearchBar.kt b/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/MifosSearchBar.kt new file mode 100644 index 00000000..4c3359a2 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/MifosSearchBar.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.mifospay.core.designsystem.icon.MifosIcons +import template.core.base.designsystem.KptTheme +import template.core.base.designsystem.theme.KptTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MifosSearchBar( + query: String, + placeHolder: String, + showClearButton: Boolean = true, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + onClearQuery: () -> Unit = {}, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + SearchBar( + modifier = modifier.fillMaxWidth(), + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + expanded = false, + onExpandedChange = {}, + enabled = enabled, + placeholder = { Text(text = placeHolder) }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (showClearButton && query.isNotEmpty()) { + IconButton(onClick = onClearQuery) { + Icon( + imageVector = MifosIcons.Close, + contentDescription = null, + ) + } + } + }, + interactionSource = null, + ) + }, + expanded = false, + onExpandedChange = {}, + shape = SearchBarDefaults.inputFieldShape, + colors = SearchBarDefaults.colors(), + tonalElevation = SearchBarDefaults.TonalElevation, + shadowElevation = SearchBarDefaults.ShadowElevation, + windowInsets = SearchBarDefaults.windowInsets, + content = {}, + ) +} + +@Composable +fun SimpleSearchBar( + query: String, + placeHolder: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + enabled: Boolean = true, + onClearQuery: () -> Unit = {}, +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .fillMaxWidth() + .height(48.dp).clickable { + onClick() + }, + placeholder = { + Text( + text = placeHolder, + style = MaterialTheme.typography.bodyMedium, + ) + }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = MifosIcons.Search, + contentDescription = "Search", + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClearQuery) { + Icon( + imageVector = MifosIcons.Close, + contentDescription = "Clear", + ) + } + } + }, + shape = KptTheme.shapes.small, + colors = TextFieldDefaults.colors( + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + focusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + unfocusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + ), + enabled = enabled, + textStyle = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +@DevicePreviews +fun PreviewMifosSearchBar() { + KptTheme { + MifosSearchBar( + query = "Hello", + placeHolder = "Search...", + onQueryChange = {}, + onSearch = {}, + onClearQuery = {}, + ) + } +} + +@Composable +@DevicePreviews +fun PreviewSimpleSearchBar() { + KptTheme { + SimpleSearchBar( + query = "", + placeHolder = "Search here...", + onQueryChange = {}, + onClearQuery = {}, + ) + } +} diff --git a/feature/make-transfer/src/commonMain/composeResources/values/strings.xml b/feature/make-transfer/src/commonMain/composeResources/values/strings.xml index 32feebc0..e08d2bf5 100644 --- a/feature/make-transfer/src/commonMain/composeResources/values/strings.xml +++ b/feature/make-transfer/src/commonMain/composeResources/values/strings.xml @@ -12,14 +12,15 @@ Loading Send money Sending to - Amount + Enter Amount Transaction successful Unable to process transfer Review Transfer - Description - To Account - From Account + Add a Message + To %1$s + From %1$s + From Account Continue Oops! No accounts found! @@ -43,4 +44,10 @@ Cannot transfer to the same account Insufficient balance + Available Balance: %1$s + Show Balance + Hide Balance + Your account has insufficient balance + + diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferScreen.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferScreen.kt index 00d64d5b..f2f60903 100644 --- a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferScreen.kt +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -235,7 +236,7 @@ private fun AccountList( onClick: (Account) -> Unit, ) { Column( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().padding(KptTheme.spacing.md), verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), ) { Text( @@ -289,15 +290,18 @@ private fun AccountItem( trailingContent = { AnimatedContent( targetState = selected, - ) { + label = "radioAnim", + ) { isSelected -> Icon( - imageVector = if (it) { + imageVector = if (isSelected) { MifosIcons.RadioButtonChecked } else { MifosIcons.RadioButtonUnchecked }, - contentDescription = stringResource(Res.string.feature_make_transfer_check_icon_description), - tint = if (it) { + contentDescription = stringResource( + Res.string.feature_make_transfer_check_icon_description, + ), + tint = if (isSelected) { KptTheme.colorScheme.primary } else { KptTheme.colorScheme.outlineVariant @@ -313,7 +317,7 @@ private fun AccountItem( } @Composable -private fun ClientCard( +fun ClientCard( client: PaymentQrData, modifier: Modifier = Modifier, ) { @@ -369,7 +373,7 @@ private fun ClientCard( } @Composable -private fun AccountBadge( +fun AccountBadge( text: String, modifier: Modifier = Modifier, borderColor: Color = KptTheme.colorScheme.primary, diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/di/MakeTransferModule.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/di/MakeTransferModule.kt index 688d6438..ea24eebf 100644 --- a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/di/MakeTransferModule.kt +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/di/MakeTransferModule.kt @@ -12,7 +12,9 @@ package org.mifospay.feature.make.transfer.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import org.mifospay.feature.make.transfer.MakeTransferViewModel +import org.mifospay.feature.make.transfer.v2.MakeTransferV2ScreenV2ViewModel val MakeTransferModule = module { viewModelOf(::MakeTransferViewModel) + viewModelOf(::MakeTransferV2ScreenV2ViewModel) } diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenV2Navigation.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenV2Navigation.kt new file mode 100644 index 00000000..70d88910 --- /dev/null +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenV2Navigation.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.make.transfer.v2 + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +@Serializable +data class MakeTransferScreenV2Route( + val amount: Int, + val accountId: Long, + val toOfficeId: Int? = null, + val toClientId: Long? = null, + val toAccountTypeId: Int? = null, + val toAccountName: String = "", + val toAccountNo: String = "", +) + +fun NavController.navigateToMakeTransferScreenV2( + toOfficeId: Int?, + toClientId: Long?, + toAccountTypeId: Int?, + toAccountId: Int, + amount: Int, + toAccountName: String, + toAccountNo: String, + navOptions: NavOptions? = null, +) { + this.navigate( + MakeTransferScreenV2Route( + toOfficeId = toOfficeId, + toClientId = toClientId, + toAccountTypeId = toAccountTypeId, + + accountId = toAccountId.toLong(), + amount = amount, + toAccountName = toAccountName, + toAccountNo = toAccountNo, + ), + navOptions, + ) +} + +fun NavGraphBuilder.makeTransferScreenV2( + navigateBack: () -> Unit, + onTransferSuccess: () -> Unit, +) { + composable { + MakeTransferScreenV2( + navigateBack = navigateBack, + onTransferSuccess = onTransferSuccess, + ) + } +} diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenV2ViewModel.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenV2ViewModel.kt new file mode 100644 index 00000000..76e53cb2 --- /dev/null +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenV2ViewModel.kt @@ -0,0 +1,332 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.make.transfer.v2 + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import io.ktor.client.request.invoke +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +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 +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_insufficient_balance +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_invalid_amount +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_same_account +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_select_account +import org.jetbrains.compose.resources.StringResource +import org.mifospay.core.common.DataState +import org.mifospay.core.common.DateHelper +import org.mifospay.core.common.StringResourceSerializer +import org.mifospay.core.common.utils.capitalizeWords +import org.mifospay.core.data.repository.ClientRepository +import org.mifospay.core.data.repository.ThirdPartyTransferRepository +import org.mifospay.core.network.model.entity.TPTResponse +import org.mifospay.core.network.model.entity.payload.TransferPayload +import org.mifospay.core.network.model.entity.templates.account.AccountOption +import org.mifospay.core.ui.utils.BaseViewModel +import kotlin.Int + +internal class MakeTransferV2ScreenV2ViewModel( + private val repository: ThirdPartyTransferRepository, + private val clientRepo: ClientRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = run { + val route = savedStateHandle.toRoute() + MakeTransferV2State( + toOfficeId = route.toOfficeId, + toClientId = route.toClientId, + toAccountType = route.toAccountTypeId, + toAccountId = route.accountId.toInt(), + toAccountName = route.toAccountName, + toAccountNo = route.toAccountNo, + amount = if (route.amount == 0) "" else route.amount.toString(), + ) + }, +) { + + init { + viewModelScope.launch { + getFromAccounts() + } + } + override fun handleAction(action: MakeTransferV2Action) { + when (action) { + MakeTransferV2Action.NavigateBack -> { + sendEvent(MakeTransferV2Event.OnNavigateBack) + } + + is MakeTransferV2Action.AmountChanged -> { + mutableStateFlow.update { + it.copy(amount = action.amount) + } + } + + is MakeTransferV2Action.DescriptionChanged -> { + mutableStateFlow.update { + it.copy(description = action.desc) + } + } + + is MakeTransferV2Action.SelectAccount -> { + mutableStateFlow.update { + it.copy( + selectedAccount = action.account, + selectedAccountBalance = state.balanceMap.getOrElse( + action.account?.accountNo ?: "", + ) { 0.0 }, + showBottomSheet = false, + ) + } + } + + is MakeTransferV2Action.DismissDialog -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + } + + is MakeTransferV2Action.InitiateTransfer -> { + mutableStateFlow.update { + it.copy(dialogState = MakeTransferV2State.DialogState.Loading) + } + validateTransfer() + } + + is MakeTransferV2Action.Internal.HandleTransferResult -> handleTransferResult(action) + + MakeTransferV2Action.CloseBottomSheet -> { + mutableStateFlow.update { + it.copy(showBottomSheet = false) + } + } + + MakeTransferV2Action.OpenBottomSheet -> { + mutableStateFlow.update { + it.copy(showBottomSheet = true) + } + } + } + } + + private suspend fun getFromAccounts() { + try { + val res = repository.getTransferTemplate() + if (res.fromAccountOptions.isNullOrEmpty()) { + mutableStateFlow.update { + it.copy( + state = MakeTransferV2State.State.NoAccounts, + ) + } + } else { + mutableStateFlow.update { + it.copy( + fromAccountOptions = res.fromAccountOptions, + ) + } + getBalanceOfAccounts() + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + state = MakeTransferV2State.State.Error(e.message ?: ""), + ) + } + } + } + + private suspend fun getBalanceOfAccounts() { + try { + val clientId = state.fromAccountOptions?.first()?.clientId ?: -1 + val result = clientRepo.getClientAccounts(clientId) + val balanceMap: Map = + result.savingsAccounts.associate { account -> + account.accountNo to account.accountBalance + } + mutableStateFlow.update { + it.copy( + state = MakeTransferV2State.State.Success, + balanceMap = balanceMap, + ) + } + val firstAccount = state.fromAccountOptions?.first() + mutableStateFlow.update { + it.copy( + selectedAccount = firstAccount, + selectedAccountBalance = balanceMap.getOrElse( + firstAccount?.accountNo ?: "", + { 0.0 }, + ), + ) + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + state = MakeTransferV2State.State.Error(e.message ?: ""), + ) + } + } + } + + private fun validateTransfer() = when { + state.amount.isBlank() -> updateErrorState(Res.string.feature_make_transfer_error_empty_amount) + + state.amount.toDoubleOrNull() == null -> updateErrorState(Res.string.feature_make_transfer_error_invalid_amount) + + state.description.isBlank() -> updateErrorState(Res.string.feature_make_transfer_error_empty_description) + + state.selectedAccount == null -> updateErrorState(Res.string.feature_make_transfer_error_select_account) + + state.selectedAccount?.accountId == state.toAccountId -> { + updateErrorState(Res.string.feature_make_transfer_error_same_account) + } + + state.amount.toDouble() > state.selectedAccountBalance -> { + updateErrorState(Res.string.feature_make_transfer_error_insufficient_balance) + } + + else -> initiateTransfer() + } + + private fun initiateTransfer() { + viewModelScope.launch { + val result = repository.makeTransfer(state.transferPayload) + + sendAction(MakeTransferV2Action.Internal.HandleTransferResult(result)) + } + } + + private fun handleTransferResult(action: MakeTransferV2Action.Internal.HandleTransferResult) { + when (action.result) { + is DataState.Loading -> { + mutableStateFlow.update { + it.copy(dialogState = MakeTransferV2State.DialogState.Loading) + } + } + + is DataState.Error -> { + mutableStateFlow.update { + it.copy(dialogState = MakeTransferV2State.DialogState.Error.StringMessage(action.result.message)) + } + } + + is DataState.Success -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + + sendEvent(MakeTransferV2Event.OnTransferSuccess) + } + } + } + + private fun updateErrorState(message: StringResource) { + mutableStateFlow.update { + it.copy(dialogState = MakeTransferV2State.DialogState.Error.ResourceMessage(message)) + } + } +} + +@Serializable +internal data class MakeTransferV2State( + val toOfficeId: Int? = null, + val toClientId: Long? = null, + val toAccountType: Int? = null, + val toAccountId: Int? = null, + val toAccountName: String = "", + val toAccountNo: String = "", + val amount: String = "", + + val showBottomSheet: Boolean = false, + val state: State = State.Loading, + val description: String = "", + val selectedAccount: AccountOption? = null, + val selectedAccountBalance: Double = 0.0, + val dialogState: DialogState? = null, + val fromAccountOptions: List? = emptyList(), + val balanceMap: Map = emptyMap(), +) { + val amountIsValid: Boolean + get() = amount.isNotEmpty() && amount.toDoubleOrNull() != null && amount.toDouble() <= selectedAccountBalance + + val descriptionIsValid: Boolean + get() = description.isNotEmpty() + + val transferPayload: TransferPayload + get() = TransferPayload( + fromOfficeId = selectedAccount?.officeId, + fromClientId = selectedAccount?.clientId, + fromAccountType = selectedAccount?.accountType?.id, + fromAccountId = selectedAccount?.accountId, + toOfficeId = toOfficeId, + toClientId = toClientId, + toAccountType = toAccountType, + toAccountId = toAccountId, + transferDate = DateHelper.formattedShortDate, + transferAmount = amount.toDoubleOrNull() ?: 0.0, + transferDescription = description.capitalizeWords(), + locale = "en_IN", + dateFormat = DateHelper.SHORT_MONTH, + ) + + @Serializable + sealed interface DialogState { + @Serializable + data object Loading : DialogState + + @Serializable + sealed class Error : DialogState { + @Serializable + data class StringMessage(val message: String) : Error() + + @Serializable + data class ResourceMessage( + @Serializable(with = StringResourceSerializer::class) + val message: StringResource, + ) : Error() + } + } + + sealed interface State { + data object Loading : State + data object NoAccounts : State + data object Success : State + data class Error(val message: String) : State + } +} + +internal sealed interface MakeTransferV2Event { + data object OnNavigateBack : MakeTransferV2Event + data object OnTransferSuccess : MakeTransferV2Event +} + +internal sealed interface MakeTransferV2Action { + data object NavigateBack : MakeTransferV2Action + + data object DismissDialog : MakeTransferV2Action + + data object InitiateTransfer : MakeTransferV2Action + + data class AmountChanged(val amount: String) : MakeTransferV2Action + + data class DescriptionChanged(val desc: String) : MakeTransferV2Action + + data class SelectAccount(val account: AccountOption?) : MakeTransferV2Action + + sealed interface Internal : MakeTransferV2Action { + data class HandleTransferResult(val result: DataState) : Internal + } + + data object OpenBottomSheet : MakeTransferV2Action + data object CloseBottomSheet : MakeTransferV2Action +} diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenv2.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenv2.kt new file mode 100644 index 00000000..e5e74b45 --- /dev/null +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/v2/MakeTransferScreenv2.kt @@ -0,0 +1,571 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.make.transfer.v2 + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.make_transfer.generated.resources.Res +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_amount +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_amount_error +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_available_balance +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_check_icon_description +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_continue_button +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_description_label +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_from_account +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_from_account_title +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_no_accounts_found +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_oops_title +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_review_title +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_show_balance +import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_to_account +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.BasicDialogState +import org.mifospay.core.designsystem.component.MifosBasicDialog +import org.mifospay.core.designsystem.component.MifosBottomSheetScaffold +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosTextField +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.network.model.entity.templates.account.AccountOption +import org.mifospay.core.ui.AvatarBox +import org.mifospay.core.ui.EmptyContentScreen +import org.mifospay.core.ui.MifosProgressIndicator +import org.mifospay.core.ui.MifosProgressIndicatorOverlay +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +internal fun MakeTransferScreenV2( + navigateBack: () -> Unit, + onTransferSuccess: () -> Unit, + modifier: Modifier = Modifier, + viewModel: MakeTransferV2ScreenV2ViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + MakeTransferV2Event.OnNavigateBack -> navigateBack.invoke() + MakeTransferV2Event.OnTransferSuccess -> onTransferSuccess.invoke() + } + } + + MakeTransferDialogsV2( + dialogState = state.dialogState, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(MakeTransferV2Action.DismissDialog) } + }, + ) + + MakeTransferScreenV2( + state = state, + modifier = modifier, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) +} + +@Composable +private fun MakeTransferDialogsV2( + dialogState: MakeTransferV2State.DialogState?, + onDismissRequest: () -> Unit, +) { + when (dialogState) { + is MakeTransferV2State.DialogState.Error -> { + val message = when (dialogState) { + is MakeTransferV2State.DialogState.Error.StringMessage -> dialogState.message + is MakeTransferV2State.DialogState.Error.ResourceMessage -> stringResource(dialogState.message) + } + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + message = message, + ), + onDismissRequest = onDismissRequest, + ) + } + + is MakeTransferV2State.DialogState.Loading -> MifosProgressIndicatorOverlay() + + null -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MakeTransferScreenV2( + state: MakeTransferV2State, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), + onAction: (MakeTransferV2Action) -> Unit, +) { + MifosBottomSheetScaffold( + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_make_transfer_review_title), + backPress = { + onAction(MakeTransferV2Action.NavigateBack) + }, + ) + }, + sheetPeekHeight = if (state.showBottomSheet) 200.dp else 0.dp, + sheetContent = { + if (state.showBottomSheet) { + AccountList( + accounts = state.fromAccountOptions ?: emptyList(), + selected = remember(state) { + { state.selectedAccount == it } + }, + onClick = { + onAction(MakeTransferV2Action.SelectAccount(it)) + }, + balanceMap = state.balanceMap, + ) + } + }, + modifier = modifier, + ) { paddingValues -> + when (state.state) { + is MakeTransferV2State.State.Error -> { + EmptyContentScreen( + title = stringResource(Res.string.feature_make_transfer_oops_title), + subTitle = stringResource(Res.string.feature_make_transfer_no_accounts_found), + iconTint = KptTheme.colorScheme.error, + ) + } + MakeTransferV2State.State.Loading -> { + MifosProgressIndicator() + } + MakeTransferV2State.State.NoAccounts -> { + EmptyContentScreen( + title = stringResource(Res.string.feature_make_transfer_oops_title), + subTitle = stringResource(Res.string.feature_make_transfer_no_accounts_found), + ) + } + MakeTransferV2State.State.Success -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(KptTheme.spacing.md) + .imePadding(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + item { + ClientCard( + name = state.toAccountName, + account = state.toAccountNo, + onEdit = { onAction(MakeTransferV2Action.NavigateBack) }, + ) + } + + if (state.selectedAccount != null) { + item { + FromAccountCard( + account = state.selectedAccount, + onAction = onAction, + isOpened = state.showBottomSheet, + balance = state.selectedAccountBalance.toString(), + ) + } + } + + item { + EnterAmountCard( + state = state, + onAction = onAction, + ) + } + + item { + MifosTextField( + label = stringResource(Res.string.feature_make_transfer_description_label), + value = state.description, + isError = !state.descriptionIsValid, + onValueChange = { onAction(MakeTransferV2Action.DescriptionChanged(it)) }, + ) + } + + item { + MifosButton( + onClick = { onAction(MakeTransferV2Action.InitiateTransfer) }, + modifier = Modifier.fillMaxWidth(), + enabled = state.amountIsValid && state.descriptionIsValid, + ) { + Text(text = stringResource(Res.string.feature_make_transfer_continue_button)) + } + } + } + } + } + } +} + +@Composable +private fun FromAccountCard( + account: AccountOption, + balance: String, + isOpened: Boolean, + onAction: (MakeTransferV2Action) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth() + .clickable { + if (isOpened) { + onAction(MakeTransferV2Action.CloseBottomSheet) + } else { + onAction(MakeTransferV2Action.OpenBottomSheet) + } + }, + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + ) { + ListItem( + headlineContent = { + BasicText( + text = stringResource( + Res.string.feature_make_transfer_from_account, + account.clientName ?: "", + ), + autoSize = TextAutoSize.StepBased( + minFontSize = 6.sp, + maxFontSize = 16.sp, + stepSize = 1.sp, + ), + style = LocalTextStyle.current.copy( + color = KptTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text(text = account.accountNo ?: "") + Text(text = stringResource(Res.string.feature_make_transfer_available_balance, balance)) + } + }, + leadingContent = { + AvatarBox( + icon = MifosIcons.Bank, + backgroundColor = KptTheme.colorScheme.surfaceContainerHigh, + ) + }, + trailingContent = { + if (isOpened) { + Icon( + imageVector = MifosIcons.KeyboardArrowUp, + contentDescription = stringResource(Res.string.feature_make_transfer_check_icon_description), + ) + } else { + Icon( + imageVector = MifosIcons.KeyboardArrowDown, + contentDescription = stringResource(Res.string.feature_make_transfer_check_icon_description), + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } +} + +@Composable +private fun EnterAmountCard( + state: MakeTransferV2State, + onAction: (MakeTransferV2Action) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth().then( + if (!state.amountIsValid) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.error, + shape = MaterialTheme.shapes.medium, + ) + } else { + Modifier + }, + ), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_make_transfer_amount), + style = KptTheme.typography.labelLarge, + ) + + TextField( + leadingIcon = { + Text( + text = "$", + style = KptTheme.typography.headlineMedium, + ) + }, + value = state.amount, + onValueChange = { + onAction(MakeTransferV2Action.AmountChanged(it)) + }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + ), + isError = !state.amountIsValid, + textStyle = KptTheme.typography.headlineMedium, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedIndicatorColor = KptTheme.colorScheme.primary, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + + if ((state.amount.toDoubleOrNull() ?: 0.0) > state.selectedAccountBalance) { + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(Res.string.feature_make_transfer_amount_error), + color = MaterialTheme.colorScheme.error, + style = KptTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun AccountList( + accounts: List, + balanceMap: Map, + selected: (AccountOption?) -> Boolean, + modifier: Modifier = Modifier, + onClick: (AccountOption?) -> Unit, +) { + Column( + modifier = modifier.fillMaxWidth().padding(KptTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_make_transfer_from_account_title), + style = KptTheme.typography.labelLarge, + ) + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + items(items = accounts) { account -> + AccountItem( + account = account, + selected = selected(account), + onClick = remember(account) { + { onClick(account) } + }, + balance = balanceMap[account?.accountNo ?: ""].toString(), + ) + } + } + } +} + +@Composable +private fun AccountItem( + account: AccountOption?, + balance: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + var revealBalance by remember { mutableStateOf(false) } + + OutlinedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + onClick = onClick, + ) { + ListItem( + headlineContent = { + Text(text = account?.clientName ?: "") + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(text = account?.accountNo ?: "") + + if (revealBalance) { + Text(text = stringResource(Res.string.feature_make_transfer_available_balance, balance)) + } else { + Text( + text = stringResource(Res.string.feature_make_transfer_show_balance), + color = KptTheme.colorScheme.primary, + style = KptTheme.typography.bodySmall, + modifier = Modifier.clickable { revealBalance = true }, + ) + } + } + }, + leadingContent = { + AvatarBox( + icon = MifosIcons.Bank, + backgroundColor = KptTheme.colorScheme.surfaceContainerHigh, + ) + }, + trailingContent = { + AnimatedContent( + targetState = selected, + label = "radioAnim", + ) { isSelected -> + Icon( + imageVector = if (isSelected) { + MifosIcons.RadioButtonChecked + } else { + MifosIcons.RadioButtonUnchecked + }, + contentDescription = stringResource( + Res.string.feature_make_transfer_check_icon_description, + ), + tint = if (isSelected) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.outlineVariant + }, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } +} + +@Composable +fun ClientCard( + name: String, + account: String, + onEdit: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + ) { + ListItem( + headlineContent = { + BasicText( + text = stringResource( + Res.string.feature_make_transfer_to_account, + name, + ), + autoSize = TextAutoSize.StepBased( + minFontSize = 6.sp, + maxFontSize = 16.sp, + stepSize = 1.sp, + ), + maxLines = 1, + style = LocalTextStyle.current.copy( + color = KptTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ), + ) + }, + supportingContent = { + Text(text = account) + }, + leadingContent = { + AvatarBox( + icon = MifosIcons.Bank, + backgroundColor = KptTheme.colorScheme.surfaceContainerHigh, + ) + }, + trailingContent = { + Icon( + imageVector = MifosIcons.Edit, + contentDescription = stringResource( + Res.string.feature_make_transfer_check_icon_description, + ), + modifier = Modifier.clickable { onEdit() }, + ) + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } +} diff --git a/feature/merchants/src/commonMain/kotlin/org/mifospay/feature/merchants/ui/MerchantScreen.kt b/feature/merchants/src/commonMain/kotlin/org/mifospay/feature/merchants/ui/MerchantScreen.kt index 5e22efcb..1358f8ad 100644 --- a/feature/merchants/src/commonMain/kotlin/org/mifospay/feature/merchants/ui/MerchantScreen.kt +++ b/feature/merchants/src/commonMain/kotlin/org/mifospay/feature/merchants/ui/MerchantScreen.kt @@ -17,11 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,18 +28,18 @@ import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import mobile_wallet.feature.merchants.generated.resources.Res -import mobile_wallet.feature.merchants.generated.resources.feature_merchants_close import mobile_wallet.feature.merchants.generated.resources.feature_merchants_empty_no_merchants_subtitle import mobile_wallet.feature.merchants.generated.resources.feature_merchants_empty_no_merchants_title import mobile_wallet.feature.merchants.generated.resources.feature_merchants_error_oops +import mobile_wallet.feature.merchants.generated.resources.feature_merchants_loading import mobile_wallet.feature.merchants.generated.resources.feature_merchants_search import mobile_wallet.feature.merchants.generated.resources.feature_merchants_unexpected_error_subtitle import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MfLoadingWheel 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.designsystem.theme.MifosTheme import org.mifospay.core.model.savingsaccount.Currency import org.mifospay.core.model.savingsaccount.DepositType @@ -55,7 +50,7 @@ import org.mifospay.core.model.savingsaccount.SubStatus import org.mifospay.core.model.savingsaccount.Summary import org.mifospay.core.model.savingsaccount.Timeline import org.mifospay.core.ui.EmptyContentScreen -import org.mifospay.core.ui.MifosProgressIndicator +import org.mifospay.core.ui.MifosSearchBar import org.mifospay.feature.merchants.MerchantUiState import org.mifospay.feature.merchants.MerchantViewModel import org.mifospay.feature.merchants.navigation.navigateToMerchantTransferScreen @@ -123,7 +118,12 @@ internal fun MerchantScreen( ) } - MerchantUiState.Loading -> MifosProgressIndicator() + MerchantUiState.Loading -> { + MfLoadingWheel( + contentDesc = stringResource(Res.string.feature_merchants_loading), + backgroundColor = KptTheme.colorScheme.surface, + ) + } is MerchantUiState.ShowMerchants -> { MerchantScreenContent( @@ -204,48 +204,13 @@ private fun SearchBarScreen( onClearQuery: () -> Unit, modifier: Modifier = Modifier, ) { - SearchBar( - inputField = { - SearchBarDefaults.InputField( - query = query, - onQueryChange = onQueryChange, - onSearch = onSearch, - expanded = false, - onExpandedChange = {}, - enabled = true, - placeholder = { - Text(text = stringResource(Res.string.feature_merchants_search)) - }, - leadingIcon = { - Icon( - imageVector = MifosIcons.Search, - contentDescription = stringResource(Res.string.feature_merchants_search), - ) - }, - trailingIcon = { - IconButton( - onClick = onClearQuery, - ) { - Icon( - imageVector = MifosIcons.Close, - contentDescription = stringResource(Res.string.feature_merchants_close), - ) - } - }, - interactionSource = null, - ) - }, - expanded = false, - onExpandedChange = {}, - modifier = modifier - .fillMaxWidth() - .padding(vertical = KptTheme.spacing.md, horizontal = KptTheme.spacing.md), - shape = SearchBarDefaults.inputFieldShape, - colors = SearchBarDefaults.colors(), - tonalElevation = SearchBarDefaults.TonalElevation, - shadowElevation = SearchBarDefaults.ShadowElevation, - windowInsets = SearchBarDefaults.windowInsets, - content = {}, + MifosSearchBar( + query = query, + placeHolder = stringResource(Res.string.feature_merchants_search), + onQueryChange = onQueryChange, + onSearch = onSearch, + onClearQuery = onClearQuery, + modifier = modifier, ) } diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64..d0d82d42 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,15 @@ Account cannot be empty Requesting payment QR but found - %1$s Failed to request payment QR: required data is missing + + Select Payee + Search VPA/Mobile/Account Number + Add Payee + Add Payee to transfer money quickly + Add + Recents + Recents + Pay + No accounts found for searched query + \ 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 66d7bebd..15fb3fbf 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 @@ -332,7 +332,7 @@ private fun SelectedAccountCard( } @Composable -private fun AccountBadge( +fun AccountBadge( text: String, modifier: Modifier = Modifier, borderColor: Color = KptTheme.colorScheme.primary, 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 16dd2181..5bad5b02 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 @@ -13,8 +13,12 @@ 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.SendMoneyViewModel +import org.mifospay.feature.send.money.selectScreen.SelectScreenViewModel +import org.mifospay.feature.send.money.v2.SendMoneyV2ViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + viewModelOf(::SelectScreenViewModel) + viewModelOf(::SendMoneyV2ViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectPayeeScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectPayeeScreen.kt new file mode 100644 index 00000000..d8c08ead --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectPayeeScreen.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money.selectScreen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +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.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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_select_account_placeholder +import mobile_wallet.feature.send_money.generated.resources.feature_select_payment_title +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bottom_bar +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_close +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_no_accounts_found +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_no_accounts_found_for_search +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_oops +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_proceed +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_selected +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_something_went_wrong +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_to_account +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosButton +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.designsystem.theme.toRoundedCornerShape +import org.mifospay.core.network.model.entity.templates.account.AccountOption +import org.mifospay.core.ui.AvatarBox +import org.mifospay.core.ui.EmptyContentScreen +import org.mifospay.core.ui.MifosProgressIndicator +import org.mifospay.core.ui.SimpleSearchBar +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun SelectPayeeScreen( + modifier: Modifier = Modifier, + navigateBack: () -> Unit, + navigateToMakeTransferV2Screen: ( + toOfficeId: Int?, + toClientId: Long?, + toAccountTypeId: Int?, + toAccountId: Int, + amount: Int, + accountName: String, + accountNo: String, + ) -> Unit, + viewModel: SelectScreenViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + is SelectScreenEvent.NavigateToTransferScreen -> { + navigateToMakeTransferV2Screen( + state.selectedAccount?.officeId, + state.selectedAccount?.clientId, + state.selectedAccount?.accountType?.id ?: 0, + state.selectedAccount?.accountId ?: 0, + state.amount.toIntOrNull() ?: 0, + state.selectedAccount?.clientName ?: "", + state.selectedAccount?.accountNo ?: "", + ) + } + + SelectScreenEvent.NavigateBack -> navigateBack() + } + } + + SelectAccountScreen( + state = state, + modifier = modifier, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SelectAccountScreen( + state: SelectScreenState, + modifier: Modifier = Modifier, + onAction: (SelectScreenAction) -> Unit, +) { + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_select_payment_title), + backPress = { + onAction(SelectScreenAction.NavigateBack) + }, + ) + }, + bottomBar = { + SendMoneyBottomBar( + showDetails = state.isProceedEnabled, + selectedAccount = state.selectedAccount, + onDeselect = { + onAction(SelectScreenAction.DeselectAccount) + }, + onClickProceed = { + onAction(SelectScreenAction.OnProceedClicked) + }, + ) + }, + ) { paddingValues -> + SelectAccountContent( + state = state, + modifier = Modifier.padding(paddingValues), + onAction = onAction, + ) + } + } +} + +@Composable +private fun SelectAccountContent( + state: SelectScreenState, + onAction: (SelectScreenAction) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + when (state.state) { + is SelectScreenState.State.Error -> { + EmptyContentScreen( + title = stringResource(Res.string.feature_send_money_oops), + subTitle = stringResource(Res.string.feature_send_money_something_went_wrong), + modifier = Modifier.fillMaxSize(), + iconTint = KptTheme.colorScheme.error, + ) + } + SelectScreenState.State.Loading -> { + MifosProgressIndicator() + } + SelectScreenState.State.NoAccounts -> { + EmptyContentScreen( + title = stringResource(Res.string.feature_send_money_oops), + subTitle = stringResource(Res.string.feature_send_money_no_accounts_found), + modifier = Modifier.fillMaxSize(), + ) + } + SelectScreenState.State.Success -> { + LazyColumn( + modifier = modifier.padding(horizontal = KptTheme.spacing.md), + ) { + stickyHeader { + SimpleSearchBar( + query = state.accountNumber, + placeHolder = stringResource(Res.string.feature_select_account_placeholder), + onQueryChange = { + onAction(SelectScreenAction.AccountNumberChanged(it)) + }, + onClearQuery = { + onAction(SelectScreenAction.AccountNumberChanged("")) + }, + ) + } + accountListContent( + state = state, + onAction = { + onAction(it) + keyboardController?.hide() + }, + selected = { state.selectedAccount == it }, + ) + if (state.filteredToAccounts?.isEmpty() == true) { + item { + EmptyContentScreen( + title = stringResource(Res.string.feature_send_money_oops), + subTitle = stringResource(Res.string.feature_send_money_no_accounts_found_for_search), + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} + +private fun LazyListScope.accountListContent( + state: SelectScreenState, + selected: (AccountOption?) -> Boolean, + onAction: (SelectScreenAction.SelectAccount) -> Unit, +) { + items(state.filteredToAccounts?.size ?: 0) { it -> + AccountCard( + account = state.filteredToAccounts?.get(it), + selected = selected, + onClick = remember(state.filteredToAccounts?.get(it)) { + { + onAction(SelectScreenAction.SelectAccount(it)) + } + }, + ) + } +} + +@Composable +private fun AccountCard( + account: AccountOption?, + selected: (AccountOption?) -> Boolean, + modifier: Modifier = Modifier, + onClick: (AccountOption?) -> Unit, +) { + ListItem( + headlineContent = { + Text(text = account?.clientName ?: "") + }, + supportingContent = { + Text(text = account?.accountNo ?: "") + }, + leadingContent = { + AvatarBox( + icon = MifosIcons.Bank, + backgroundColor = KptTheme.colorScheme.tertiary, + ) + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + trailingContent = { + AnimatedVisibility( + visible = selected(account), + ) { + Icon( + imageVector = MifosIcons.Check, + contentDescription = stringResource(Res.string.feature_send_money_selected), + ) + } + }, + modifier = modifier + .fillMaxWidth() + .clickable { + onClick(account) + }, + ) +} + +@Composable +private fun SendMoneyBottomBar( + showDetails: Boolean, + selectedAccount: AccountOption?, + modifier: Modifier = Modifier, + onClickProceed: () -> Unit, + onDeselect: () -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = KptTheme.shapes.toRoundedCornerShape( + topStart = KptTheme.spacing.sm, + topEnd = KptTheme.spacing.sm, + ), + color = KptTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + AnimatedVisibility( + visible = showDetails && selectedAccount != null, + label = stringResource(Res.string.feature_send_money_bottom_bar), + enter = fadeIn() + slideInVertically( + initialOffsetY = { fullHeight -> + fullHeight / 4 + }, + ), + exit = fadeOut(tween(300)) + slideOutVertically( + targetOffsetY = { fullHeight -> + fullHeight / 4 + }, + ), + ) { + selectedAccount?.let { + SelectedAccountCard( + account = selectedAccount, + onDeselect = onDeselect, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + MifosButton( + onClick = onClickProceed, + enabled = showDetails, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(Res.string.feature_send_money_proceed)) + } + } + } +} + +@Composable +private fun SelectedAccountCard( + account: AccountOption, + modifier: Modifier = Modifier, + onDeselect: () -> Unit, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + Text( + text = stringResource(Res.string.feature_send_money_to_account), + style = KptTheme.typography.labelLarge, + ) + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + border = BorderStroke(1.dp, KptTheme.colorScheme.primary), + ) { + ListItem( + headlineContent = { + Text( + text = account.clientName ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text(text = account.accountNo ?: "") + }, + leadingContent = { + AvatarBox(icon = MifosIcons.Bank) + }, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onDeselect, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.feature_send_money_close), + ) + } + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectScreenNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectScreenNavigation.kt new file mode 100644 index 00000000..98b067fb --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectScreenNavigation.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money.selectScreen + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +@Serializable +data object SelectAccountRoute + +fun NavController.navigateToSelectAccountScreen(navOptions: NavOptions? = null) { + this.navigate(SelectAccountRoute, navOptions) +} + +fun NavGraphBuilder.selectAccountScreenDestination( + navigateBack: () -> Unit, + navigateToMakeTransferV2Screen: ( + toOfficeId: Int?, + toClientId: Long?, + toAccountTypeId: Int?, + toAccountId: Int, + amount: Int, + accountName: String, + accountNo: String, + ) -> Unit, +) { + composable { + SelectPayeeScreen( + navigateToMakeTransferV2Screen = navigateToMakeTransferV2Screen, + navigateBack = navigateBack, + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectScreenViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectScreenViewModel.kt new file mode 100644 index 00000000..f57d42bd --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/selectScreen/SelectScreenViewModel.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money.selectScreen + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.data.repository.ThirdPartyTransferRepository +import org.mifospay.core.network.model.entity.templates.account.AccountOption +import org.mifospay.core.ui.utils.BaseViewModel + +class SelectScreenViewModel( + private val repository: ThirdPartyTransferRepository, +) : BaseViewModel( + initialState = SelectScreenState(), +) { + + init { + viewModelScope.launch { + getToAccounts() + } + } + + override fun handleAction(action: SelectScreenAction) { + when (action) { + is SelectScreenAction.AccountNumberChanged -> { + val filteredAccounts = state.toAccountOptions?.filter { account -> + account.accountNo?.contains(action.accountNumber) == true + } + mutableStateFlow.update { + it.copy( + accountNumber = action.accountNumber, + filteredToAccounts = filteredAccounts, + ) + } + } + + SelectScreenAction.NavigateBack -> { + sendEvent(SelectScreenEvent.NavigateBack) + } + + is SelectScreenAction.SelectAccount -> { + mutableStateFlow.update { + it.copy(selectedAccount = action.account) + } + } + + SelectScreenAction.DeselectAccount -> { + mutableStateFlow.update { + it.copy(selectedAccount = null) + } + } + + SelectScreenAction.OnProceedClicked -> { + sendEvent(SelectScreenEvent.NavigateToTransferScreen) + } + } + } + + private suspend fun getToAccounts() { + try { + val res = repository.getTransferTemplate() + if (res.toAccountOptions.isNullOrEmpty()) { + mutableStateFlow.update { + it.copy( + state = SelectScreenState.State.NoAccounts, + ) + } + } else { + mutableStateFlow.update { + it.copy( + state = SelectScreenState.State.Success, + toAccountOptions = res.toAccountOptions, + filteredToAccounts = res.toAccountOptions, + ) + } + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + state = SelectScreenState.State.Error(e.message ?: ""), + ) + } + } + } +} + +@Serializable +data class SelectScreenState( + val amount: String = "", + val accountNumber: String = "", + val selectedAccount: AccountOption? = null, + val state: State = State.Loading, + val toAccountOptions: List? = emptyList(), + val filteredToAccounts: List? = emptyList(), +) { + + val isProceedEnabled: Boolean + get() = selectedAccount != null + + sealed interface State { + data object Loading : State + data object NoAccounts : State + data object Success : State + data class Error(val message: String) : State + } +} + +sealed interface SelectScreenEvent { + data object NavigateToTransferScreen : SelectScreenEvent + + data object NavigateBack : SelectScreenEvent +} + +sealed interface SelectScreenAction { + data object NavigateBack : SelectScreenAction + + data class AccountNumberChanged(val accountNumber: String) : SelectScreenAction + + data class SelectAccount(val account: AccountOption?) : SelectScreenAction + + data object DeselectAccount : SelectScreenAction + + data object OnProceedClicked : SelectScreenAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2Navigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2Navigation.kt new file mode 100644 index 00000000..7cbe98b9 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2Navigation.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money.v2 + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +@Serializable +data object SendMoneyRoute + +fun NavController.navigateToSendMoneyV2Screen(navOptions: NavOptions? = null) { + this.navigate(SendMoneyRoute, navOptions) +} + +fun NavGraphBuilder.sendMoneyScreenDestination( + navigateToSelectAccountScreen: () -> Unit, + navigateToBeneficiary: () -> Unit, + navigateBack: () -> Unit, +) { + composable { + SendMoneyv2Screen( + navigateToSelectAccountScreen = navigateToSelectAccountScreen, + navigateBack = navigateBack, + navigateToBeneficiary = navigateToBeneficiary, + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2Screen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2Screen.kt new file mode 100644 index 00000000..dfeb8c95 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2Screen.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money.v2 + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_select_account_placeholder +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_add_icon_desc +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_add_payee_subtitle +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_add_payee_title +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_pay_button +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_recents_title +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.MifosBottomSheetScaffold +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosTextUserImage +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.SimpleSearchBar +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun SendMoneyv2Screen( + navigateToSelectAccountScreen: () -> Unit, + navigateBack: () -> Unit, + navigateToBeneficiary: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SendMoneyV2ViewModel = koinViewModel(), +) { + EventsEffect(viewModel) { event -> + when (event) { + SendMoneyV2Event.NavigateToSearchAccountSelection -> navigateToSelectAccountScreen() + + SendMoneyV2Event.NavigateBack -> navigateBack() + + SendMoneyV2Event.NavigateToBeneficiary -> navigateToBeneficiary() + } + } + + SendMoneyScreen( + modifier = modifier, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun SendMoneyScreen( + modifier: Modifier = Modifier, + onAction: (SendMoneyV2Action) -> Unit, +) { + MifosBottomSheetScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_send), + backPress = { + onAction(SendMoneyV2Action.NavigateBack) + }, + ) + }, + sheetContent = { + // TODO : If we can get recent payment details in self with toAccount number and amount + // show those list with pay button here and on click it should navigate to that id and price +// RecentBottomSheet() + }, + sheetPeekHeight = 0.dp, + ) { paddingValues -> + Column( + Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = KptTheme.spacing.md), + ) { + SimpleSearchBar( + query = "", + placeHolder = stringResource(Res.string.feature_select_account_placeholder), + onQueryChange = {}, + onClick = { + onAction(SendMoneyV2Action.OnSearchBarClicked) + }, + enabled = false, + ) + Spacer(Modifier.height(KptTheme.spacing.md)) + AddPayeeCard( + onClick = { + onAction(SendMoneyV2Action.OnAddPayeeClicked) + }, + ) + } + } +} + +@Composable +fun AddPayeeCard( + title: String = stringResource(Res.string.feature_send_money_add_payee_title), + subtitle: String = stringResource(Res.string.feature_send_money_add_payee_subtitle), + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = KptTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primary.copy(0.1f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(32.dp) + .background( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = stringResource(Res.string.feature_send_money_add_icon_desc), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.width(KptTheme.spacing.md)) + + Column { + Text( + text = title, + style = KptTheme.typography.titleSmall, + ) + Text( + text = subtitle, + style = KptTheme.typography.bodySmall, + ) + } + } + } +} + +// TODO: remove it once we get data from self api +data class RecentTransaction( + val name: String, + val toAccount: String, + val amount: String, +) + +@Composable +private fun RecentBottomSheet() { + // TODO : when we get api pass data from api. + val recents = listOf( + RecentTransaction("Alex Doe", "**** **** 1234", "₹1500"), + RecentTransaction("Jane Smith", "**** **** 5678", "₹2200"), + RecentTransaction("Mike Johnson", "**** **** 9012", "₹800"), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + Icon( + imageVector = MifosIcons.History, + contentDescription = stringResource(Res.string.feature_send_money_recents_title), + tint = KptTheme.colorScheme.primary, + ) + Text( + text = stringResource(Res.string.feature_send_money_recents_title), + style = KptTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + recents.forEach { transaction -> + RecentTransactionItem(transaction = transaction) + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + } + } +} + +@Composable +private fun RecentTransactionItem(transaction: RecentTransaction) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(KptTheme.shapes.medium) + .padding(horizontal = KptTheme.spacing.md, vertical = KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + MifosTextUserImage( + text = transaction.name.first().toString(), + size = 40.dp, + ) + + Column { + Text( + text = transaction.name, + style = KptTheme.typography.bodyLarge, + ) + Text( + text = transaction.toAccount, + style = KptTheme.typography.bodyMedium, + ) + } + } + + MifosOutlinedButton( + onClick = { + // TODO pass the id and amount from here + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = KptTheme.colorScheme.primary, + ), + ) { + Text(stringResource(Res.string.feature_send_money_pay_button)) + } + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2ViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2ViewModel.kt new file mode 100644 index 00000000..82575ba7 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/v2/SendMoneyv2ViewModel.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money.v2 + +import kotlinx.serialization.Serializable +import org.mifospay.core.ui.utils.BaseViewModel + +class SendMoneyV2ViewModel() : BaseViewModel( + initialState = SendMoneyV2State, +) { + + override fun handleAction(action: SendMoneyV2Action) { + when (action) { + SendMoneyV2Action.NavigateBack -> { + sendEvent(SendMoneyV2Event.NavigateBack) + } + + SendMoneyV2Action.OnSearchBarClicked -> { + sendEvent(SendMoneyV2Event.NavigateToSearchAccountSelection) + } + + SendMoneyV2Action.OnAddPayeeClicked -> { + sendEvent(SendMoneyV2Event.NavigateToBeneficiary) + } + } + } +} + +@Serializable +data object SendMoneyV2State + +sealed interface SendMoneyV2Event { + data object NavigateToSearchAccountSelection : SendMoneyV2Event + data object NavigateBack : SendMoneyV2Event + data object NavigateToBeneficiary : SendMoneyV2Event +} + +sealed interface SendMoneyV2Action { + data object NavigateBack : SendMoneyV2Action + + data object OnSearchBarClicked : SendMoneyV2Action + + data object OnAddPayeeClicked : SendMoneyV2Action +}