feat : send money flow change (#1926)

This commit is contained in:
JILAKARA REVANTH KUMAR 2025-09-10 14:10:15 +05:30 committed by GitHub
parent 9b4ee9de23
commit e3061d29cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2194 additions and 90 deletions

View File

@ -45,7 +45,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.MifosSplash" android:theme="@style/Theme.MifosSplash"
android:windowSoftInputMode="adjustPan|adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@ -15,6 +15,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.navOptions import androidx.navigation.navOptions
import org.mifospay.core.ui.utility.TabContent import org.mifospay.core.ui.utility.TabContent
import org.mifospay.feature.accounts.AccountsScreen 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.addEditBeneficiaryScreen
import org.mifospay.feature.accounts.beneficiary.navigateToBeneficiaryAddEdit import org.mifospay.feature.accounts.beneficiary.navigateToBeneficiaryAddEdit
import org.mifospay.feature.accounts.savingsaccount.SavingsAddEditType 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.navigation.transferScreen
import org.mifospay.feature.make.transfer.success.navigateTransferSuccess import org.mifospay.feature.make.transfer.success.navigateTransferSuccess
import org.mifospay.feature.make.transfer.success.transferSuccessScreen 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.merchants.navigation.merchantTransferScreen
import org.mifospay.feature.notification.navigateToNotification import org.mifospay.feature.notification.navigateToNotification
import org.mifospay.feature.notification.notificationScreen 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.SEND_MONEY_BASE_ROUTE
import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen
import org.mifospay.feature.send.money.navigation.sendMoneyScreen 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.settings.navigation.settingsScreen
import org.mifospay.feature.standing.instruction.createOrUpdate.addEditSIScreen import org.mifospay.feature.standing.instruction.createOrUpdate.addEditSIScreen
import org.mifospay.feature.standing.instruction.details.siDetailsScreen import org.mifospay.feature.standing.instruction.details.siDetailsScreen
@ -150,7 +157,7 @@ internal fun MifosNavHost(
onRequest = { onRequest = {
navController.navigateToShowQrScreen() navController.navigateToShowQrScreen()
}, },
onPay = navController::navigateToSendMoneyScreen, onPay = navController::navigateToSendMoneyV2Screen,
navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToTransactionDetail = navController::navigateToSpecificTransaction,
navigateToAccountDetail = navController::navigateToSavingAccountDetails, navigateToAccountDetail = navController::navigateToSavingAccountDetails,
navigateToHistory = navController::navigateToHistory, navigateToHistory = navController::navigateToHistory,
@ -277,6 +284,37 @@ internal fun MifosNavHost(
navigateToScanQrScreen = navController::navigateToScanQr, 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( transferScreen(
navigateBack = navController::popBackStack, navigateBack = navController::popBackStack,
onTransferSuccess = { onTransferSuccess = {

View File

@ -32,7 +32,7 @@ interface ClientRepository {
suspend fun updateClientImage(clientId: Long, image: String): DataState<String> suspend fun updateClientImage(clientId: Long, image: String): DataState<String>
suspend fun getClientAccounts(clientId: Long): Flow<DataState<ClientAccountsEntity>> suspend fun getClientAccounts(clientId: Long): ClientAccountsEntity
suspend fun getAccounts(clientId: Long, accountType: String): Flow<DataState<List<Account>>> suspend fun getAccounts(clientId: Long, accountType: String): Flow<DataState<List<Account>>>

View File

@ -9,14 +9,13 @@
*/ */
package org.mifospay.core.data.repository package org.mifospay.core.data.repository
import kotlinx.coroutines.flow.Flow
import org.mifospay.core.common.DataState import org.mifospay.core.common.DataState
import org.mifospay.core.network.model.entity.TPTResponse import org.mifospay.core.network.model.entity.TPTResponse
import org.mifospay.core.network.model.entity.payload.TransferPayload import org.mifospay.core.network.model.entity.payload.TransferPayload
import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate
interface ThirdPartyTransferRepository { interface ThirdPartyTransferRepository {
suspend fun getTransferTemplate(): Flow<DataState<AccountOptionsTemplate>> suspend fun getTransferTemplate(): AccountOptionsTemplate
suspend fun makeTransfer(payload: TransferPayload): Flow<DataState<TPTResponse>> suspend fun makeTransfer(payload: TransferPayload): DataState<TPTResponse>
} }

View File

@ -90,10 +90,9 @@ class ClientRepositoryImpl(
} }
} }
override suspend fun getClientAccounts(clientId: Long): Flow<DataState<ClientAccountsEntity>> { override suspend fun getClientAccounts(clientId: Long): ClientAccountsEntity {
return apiManager.clientsApi return apiManager.clientsApi
.getClientAccounts(clientId) .getClientAccounts(clientId)
.asDataStateFlow().flowOn(ioDispatcher)
} }
override suspend fun getAccounts( override suspend fun getAccounts(

View File

@ -10,29 +10,29 @@
package org.mifospay.core.data.repositoryImpl package org.mifospay.core.data.repositoryImpl
import kotlinx.coroutines.CoroutineDispatcher 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.DataState
import org.mifospay.core.common.asDataStateFlow
import org.mifospay.core.data.repository.ThirdPartyTransferRepository 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.TPTResponse
import org.mifospay.core.network.model.entity.payload.TransferPayload import org.mifospay.core.network.model.entity.payload.TransferPayload
import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate
class ThirdPartyTransferRepositoryImpl( class ThirdPartyTransferRepositoryImpl(
private val apiManager: FineractApiManager, private val apiManager: SelfServiceApiManager,
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
) : ThirdPartyTransferRepository { ) : ThirdPartyTransferRepository {
override suspend fun getTransferTemplate(): Flow<DataState<AccountOptionsTemplate>> { override suspend fun getTransferTemplate(): AccountOptionsTemplate {
return apiManager.thirdPartyTransferApi return apiManager.thirdPartyTransferApi
.accountTransferTemplate() .accountTransferTemplate()
.asDataStateFlow().flowOn(ioDispatcher)
} }
override suspend fun makeTransfer(payload: TransferPayload): Flow<DataState<TPTResponse>> { override suspend fun makeTransfer(payload: TransferPayload): DataState<TPTResponse> {
return apiManager.thirdPartyTransferApi return try {
.makeTransfer(payload) val result = apiManager.thirdPartyTransferApi
.asDataStateFlow().flowOn(ioDispatcher) .makeTransfer(payload)
DataState.Success(result)
} catch (e: Exception) {
DataState.Error(e)
}
} }
} }

View File

@ -124,6 +124,7 @@ fun MifosTextField(
errorText: String? = null, errorText: String? = null,
onClickClearIcon: () -> Unit = { onValueChange("") }, onClickClearIcon: () -> Unit = { onValueChange("") },
visualTransformation: VisualTransformation = VisualTransformation.None, visualTransformation: VisualTransformation = VisualTransformation.None,
textStyle: TextStyle? = null,
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = true, singleLine: Boolean = true,
maxLines: Int = if (singleLine) 1 else Int.Companion.MAX_VALUE, maxLines: Int = if (singleLine) 1 else Int.Companion.MAX_VALUE,
@ -177,9 +178,9 @@ fun MifosTextField(
) )
} }
}, },
textStyle = LocalDensity.current.run { textStyle = (textStyle ?: TextStyle()).copy(
TextStyle(color = KptTheme.colorScheme.onSurface) color = KptTheme.colorScheme.onSurface,
}, ),
) )
} }

View File

@ -25,10 +25,13 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit 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.FlashOff
import androidx.compose.material.icons.filled.FlashOn 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.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown 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.Person
import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.PhotoLibrary
@ -78,6 +81,7 @@ object MifosIcons {
val ChevronRight: ImageVector = Icons.Filled.ChevronRight val ChevronRight: ImageVector = Icons.Filled.ChevronRight
val QrCode: ImageVector = Icons.Filled.QrCode val QrCode: ImageVector = Icons.Filled.QrCode
val Close: ImageVector = Icons.Filled.Close val Close: ImageVector = Icons.Filled.Close
val Error: ImageVector = Icons.Filled.Error
val AttachMoney: ImageVector = Icons.Filled.AttachMoney val AttachMoney: ImageVector = Icons.Filled.AttachMoney
val OutlinedVisibilityOff: ImageVector = Icons.Outlined.VisibilityOff val OutlinedVisibilityOff: ImageVector = Icons.Outlined.VisibilityOff
val OutlinedVisibility: ImageVector = Icons.Outlined.Visibility val OutlinedVisibility: ImageVector = Icons.Outlined.Visibility
@ -85,6 +89,7 @@ object MifosIcons {
val Visibility: ImageVector = Icons.Filled.Visibility val Visibility: ImageVector = Icons.Filled.Visibility
val Check: ImageVector = Icons.Default.Check val Check: ImageVector = Icons.Default.Check
val KeyboardArrowDown: ImageVector = Icons.Default.KeyboardArrowDown val KeyboardArrowDown: ImageVector = Icons.Default.KeyboardArrowDown
val KeyboardArrowUp: ImageVector = Icons.Default.KeyboardArrowUp
val Home = Icons.Outlined.Home val Home = Icons.Outlined.Home
val HomeBoarder = Icons.Rounded.Home val HomeBoarder = Icons.Rounded.Home
val Payment = Icons.Rounded.SwapHoriz val Payment = Icons.Rounded.SwapHoriz
@ -129,4 +134,5 @@ object MifosIcons {
val Scan = Icons.Outlined.QrCodeScanner val Scan = Icons.Outlined.QrCodeScanner
val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked
val RadioButtonChecked = Icons.Filled.RadioButtonChecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked
val History = Icons.Default.History
} }

View File

@ -15,7 +15,7 @@ import kotlinx.serialization.Serializable
data class AccountOption( data class AccountOption(
val accountId: Int? = null, val accountId: Int? = null,
val accountNo: String? = 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 clientId: Long? = null,
val clientName: String? = null, val clientName: String? = null,
val officeId: Int? = null, val officeId: Int? = null,

View File

@ -53,7 +53,7 @@ interface ClientService {
): Unit ): Unit
@GET(ApiEndPoints.CLIENTS + "/{clientId}/accounts") @GET(ApiEndPoints.CLIENTS + "/{clientId}/accounts")
suspend fun getClientAccounts(@Path("clientId") clientId: Long): Flow<ClientAccountsEntity> suspend fun getClientAccounts(@Path("clientId") clientId: Long): ClientAccountsEntity
@GET(ApiEndPoints.CLIENTS + "/{clientId}/accounts") @GET(ApiEndPoints.CLIENTS + "/{clientId}/accounts")
fun getAccounts( fun getAccounts(

View File

@ -12,7 +12,6 @@ package org.mifospay.core.network.services
import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.Body
import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.POST 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.TPTResponse
import org.mifospay.core.network.model.entity.payload.TransferPayload import org.mifospay.core.network.model.entity.payload.TransferPayload
import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate import org.mifospay.core.network.model.entity.templates.account.AccountOptionsTemplate
@ -20,8 +19,8 @@ import org.mifospay.core.network.utils.ApiEndPoints
interface ThirdPartyTransferService { interface ThirdPartyTransferService {
@GET(ApiEndPoints.ACCOUNT_TRANSFER + "/template?type=tpt") @GET(ApiEndPoints.ACCOUNT_TRANSFER + "/template?type=tpt")
suspend fun accountTransferTemplate(): Flow<AccountOptionsTemplate> suspend fun accountTransferTemplate(): AccountOptionsTemplate
@POST(ApiEndPoints.ACCOUNT_TRANSFER + "?type=tpt") @POST(ApiEndPoints.ACCOUNT_TRANSFER + "?type=tpt")
suspend fun makeTransfer(@Body transferPayload: TransferPayload): Flow<TPTResponse> suspend fun makeTransfer(@Body transferPayload: TransferPayload): TPTResponse
} }

View File

@ -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 = {},
)
}
}

View File

@ -12,14 +12,15 @@
<string name="feature_make_transfer_loading">Loading</string> <string name="feature_make_transfer_loading">Loading</string>
<string name="feature_make_transfer_send_money">Send money</string> <string name="feature_make_transfer_send_money">Send money</string>
<string name="feature_make_transfer_sending_to">Sending to</string> <string name="feature_make_transfer_sending_to">Sending to</string>
<string name="feature_make_transfer_amount">Amount</string> <string name="feature_make_transfer_amount">Enter Amount</string>
<string name="feature_make_transfer_transaction_success">Transaction successful</string> <string name="feature_make_transfer_transaction_success">Transaction successful</string>
<string name="feature_make_transfer_transaction_unable_to_process">Unable to process transfer</string> <string name="feature_make_transfer_transaction_unable_to_process">Unable to process transfer</string>
<string name="feature_make_transfer_review_title">Review Transfer</string> <string name="feature_make_transfer_review_title">Review Transfer</string>
<string name="feature_make_transfer_description_label">Description</string> <string name="feature_make_transfer_description_label">Add a Message</string>
<string name="feature_make_transfer_to_account">To Account</string> <string name="feature_make_transfer_to_account">To %1$s</string>
<string name="feature_make_transfer_from_account">From Account</string> <string name="feature_make_transfer_from_account">From %1$s</string>
<string name="feature_make_transfer_from_account_title">From Account</string>
<string name="feature_make_transfer_continue_button">Continue</string> <string name="feature_make_transfer_continue_button">Continue</string>
<string name="feature_make_transfer_oops_title">Oops!</string> <string name="feature_make_transfer_oops_title">Oops!</string>
<string name="feature_make_transfer_no_accounts_found">No accounts found!</string> <string name="feature_make_transfer_no_accounts_found">No accounts found!</string>
@ -43,4 +44,10 @@
<string name="feature_make_transfer_error_same_account">Cannot transfer to the same account</string> <string name="feature_make_transfer_error_same_account">Cannot transfer to the same account</string>
<string name="feature_make_transfer_error_insufficient_balance">Insufficient balance</string> <string name="feature_make_transfer_error_insufficient_balance">Insufficient balance</string>
<string name="feature_make_transfer_available_balance">Available Balance: %1$s</string>
<string name="feature_make_transfer_show_balance">Show Balance</string>
<string name="feature_make_transfer_hide_balance">Hide Balance</string>
<string name="feature_make_transfer_amount_error">Your account has insufficient balance</string>
</resources> </resources>

View File

@ -33,6 +33,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -235,7 +236,7 @@ private fun AccountList(
onClick: (Account) -> Unit, onClick: (Account) -> Unit,
) { ) {
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().padding(KptTheme.spacing.md),
verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md),
) { ) {
Text( Text(
@ -289,15 +290,18 @@ private fun AccountItem(
trailingContent = { trailingContent = {
AnimatedContent( AnimatedContent(
targetState = selected, targetState = selected,
) { label = "radioAnim",
) { isSelected ->
Icon( Icon(
imageVector = if (it) { imageVector = if (isSelected) {
MifosIcons.RadioButtonChecked MifosIcons.RadioButtonChecked
} else { } else {
MifosIcons.RadioButtonUnchecked MifosIcons.RadioButtonUnchecked
}, },
contentDescription = stringResource(Res.string.feature_make_transfer_check_icon_description), contentDescription = stringResource(
tint = if (it) { Res.string.feature_make_transfer_check_icon_description,
),
tint = if (isSelected) {
KptTheme.colorScheme.primary KptTheme.colorScheme.primary
} else { } else {
KptTheme.colorScheme.outlineVariant KptTheme.colorScheme.outlineVariant
@ -313,7 +317,7 @@ private fun AccountItem(
} }
@Composable @Composable
private fun ClientCard( fun ClientCard(
client: PaymentQrData, client: PaymentQrData,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -369,7 +373,7 @@ private fun ClientCard(
} }
@Composable @Composable
private fun AccountBadge( fun AccountBadge(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
borderColor: Color = KptTheme.colorScheme.primary, borderColor: Color = KptTheme.colorScheme.primary,

View File

@ -12,7 +12,9 @@ package org.mifospay.feature.make.transfer.di
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
import org.mifospay.feature.make.transfer.MakeTransferViewModel import org.mifospay.feature.make.transfer.MakeTransferViewModel
import org.mifospay.feature.make.transfer.v2.MakeTransferV2ScreenV2ViewModel
val MakeTransferModule = module { val MakeTransferModule = module {
viewModelOf(::MakeTransferViewModel) viewModelOf(::MakeTransferViewModel)
viewModelOf(::MakeTransferV2ScreenV2ViewModel)
} }

View File

@ -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<MakeTransferScreenV2Route> {
MakeTransferScreenV2(
navigateBack = navigateBack,
onTransferSuccess = onTransferSuccess,
)
}
}

View File

@ -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<MakeTransferV2State, MakeTransferV2Event, MakeTransferV2Action>(
initialState = run {
val route = savedStateHandle.toRoute<MakeTransferScreenV2Route>()
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<String, Double> =
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<AccountOption>? = emptyList(),
val balanceMap: Map<String, Double> = 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<TPTResponse>) : Internal
}
data object OpenBottomSheet : MakeTransferV2Action
data object CloseBottomSheet : MakeTransferV2Action
}

View File

@ -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<AccountOption?>,
balanceMap: Map<String, Double>,
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,
),
)
}
}

View File

@ -17,11 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -33,18 +28,18 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import mobile_wallet.feature.merchants.generated.resources.Res 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_subtitle
import mobile_wallet.feature.merchants.generated.resources.feature_merchants_empty_no_merchants_title 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_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_search
import mobile_wallet.feature.merchants.generated.resources.feature_merchants_unexpected_error_subtitle import mobile_wallet.feature.merchants.generated.resources.feature_merchants_unexpected_error_subtitle
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel 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.MifosScaffold
import org.mifospay.core.designsystem.component.rememberMifosPullToRefreshState 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.designsystem.theme.MifosTheme
import org.mifospay.core.model.savingsaccount.Currency import org.mifospay.core.model.savingsaccount.Currency
import org.mifospay.core.model.savingsaccount.DepositType 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.Summary
import org.mifospay.core.model.savingsaccount.Timeline import org.mifospay.core.model.savingsaccount.Timeline
import org.mifospay.core.ui.EmptyContentScreen 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.MerchantUiState
import org.mifospay.feature.merchants.MerchantViewModel import org.mifospay.feature.merchants.MerchantViewModel
import org.mifospay.feature.merchants.navigation.navigateToMerchantTransferScreen 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 -> { is MerchantUiState.ShowMerchants -> {
MerchantScreenContent( MerchantScreenContent(
@ -204,48 +204,13 @@ private fun SearchBarScreen(
onClearQuery: () -> Unit, onClearQuery: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
SearchBar( MifosSearchBar(
inputField = { query = query,
SearchBarDefaults.InputField( placeHolder = stringResource(Res.string.feature_merchants_search),
query = query, onQueryChange = onQueryChange,
onQueryChange = onQueryChange, onSearch = onSearch,
onSearch = onSearch, onClearQuery = onClearQuery,
expanded = false, modifier = modifier,
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 = {},
) )
} }

View File

@ -38,4 +38,15 @@
<string name="feature_send_money_error_account_cannot_be_empty">Account cannot be empty</string> <string name="feature_send_money_error_account_cannot_be_empty">Account cannot be empty</string>
<string name="feature_send_money_error_requesting_payment_qr_but_found">Requesting payment QR but found - %1$s</string> <string name="feature_send_money_error_requesting_payment_qr_but_found">Requesting payment QR but found - %1$s</string>
<string name="feature_send_money_error_requesting_payment_qr_data_missing">Failed to request payment QR: required data is missing</string> <string name="feature_send_money_error_requesting_payment_qr_data_missing">Failed to request payment QR: required data is missing</string>
<string name="feature_select_payment_title">Select Payee</string>
<string name="feature_select_account_placeholder">Search VPA/Mobile/Account Number</string>
<string name="feature_send_money_add_payee_title">Add Payee</string>
<string name="feature_send_money_add_payee_subtitle">Add Payee to transfer money quickly</string>
<string name="feature_send_money_add_icon_desc">Add</string>
<string name="feature_send_money_recents_title">Recents</string>
<string name="feature_send_money_recents_icon_desc">Recents</string>
<string name="feature_send_money_pay_button">Pay</string>
<string name="feature_send_money_no_accounts_found_for_search">No accounts found for searched query</string>
</resources> </resources>

View File

@ -332,7 +332,7 @@ private fun SelectedAccountCard(
} }
@Composable @Composable
private fun AccountBadge( fun AccountBadge(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
borderColor: Color = KptTheme.colorScheme.primary, borderColor: Color = KptTheme.colorScheme.primary,

View File

@ -13,8 +13,12 @@ import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
import org.mifospay.feature.send.money.ScannerModule import org.mifospay.feature.send.money.ScannerModule
import org.mifospay.feature.send.money.SendMoneyViewModel 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 { val SendMoneyModule = module {
includes(ScannerModule) includes(ScannerModule)
viewModelOf(::SendMoneyViewModel) viewModelOf(::SendMoneyViewModel)
viewModelOf(::SelectScreenViewModel)
viewModelOf(::SendMoneyV2ViewModel)
} }

View File

@ -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,
),
)
}
}
}

View File

@ -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<SelectAccountRoute> {
SelectPayeeScreen(
navigateToMakeTransferV2Screen = navigateToMakeTransferV2Screen,
navigateBack = navigateBack,
)
}
}

View File

@ -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<SelectScreenState, SelectScreenEvent, SelectScreenAction>(
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<AccountOption>? = emptyList(),
val filteredToAccounts: List<AccountOption>? = 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
}

View File

@ -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<SendMoneyRoute> {
SendMoneyv2Screen(
navigateToSelectAccountScreen = navigateToSelectAccountScreen,
navigateBack = navigateBack,
navigateToBeneficiary = navigateToBeneficiary,
)
}
}

View File

@ -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))
}
}
}

View File

@ -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<SendMoneyV2State, SendMoneyV2Event, SendMoneyV2Action>(
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
}