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:exported="true"
android:theme="@style/Theme.MifosSplash"
android:windowSoftInputMode="adjustPan|adjustResize">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -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 = {

View File

@ -32,7 +32,7 @@ interface ClientRepository {
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>>>

View File

@ -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<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
.getClientAccounts(clientId)
.asDataStateFlow().flowOn(ioDispatcher)
}
override suspend fun getAccounts(

View File

@ -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<DataState<AccountOptionsTemplate>> {
override suspend fun getTransferTemplate(): AccountOptionsTemplate {
return apiManager.thirdPartyTransferApi
.accountTransferTemplate()
.asDataStateFlow().flowOn(ioDispatcher)
}
override suspend fun makeTransfer(payload: TransferPayload): Flow<DataState<TPTResponse>> {
return apiManager.thirdPartyTransferApi
.makeTransfer(payload)
.asDataStateFlow().flowOn(ioDispatcher)
override suspend fun makeTransfer(payload: TransferPayload): DataState<TPTResponse> {
return try {
val result = apiManager.thirdPartyTransferApi
.makeTransfer(payload)
DataState.Success(result)
} catch (e: Exception) {
DataState.Error(e)
}
}
}

View File

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

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.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
}

View File

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

View File

@ -53,7 +53,7 @@ interface ClientService {
): Unit
@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")
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.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<AccountOptionsTemplate>
suspend fun accountTransferTemplate(): AccountOptionsTemplate
@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_send_money">Send money</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_unable_to_process">Unable to process 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_to_account">To Account</string>
<string name="feature_make_transfer_from_account">From Account</string>
<string name="feature_make_transfer_description_label">Add a Message</string>
<string name="feature_make_transfer_to_account">To %1$s</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_oops_title">Oops!</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_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>

View File

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

View File

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

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

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_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_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>

View File

@ -332,7 +332,7 @@ private fun SelectedAccountCard(
}
@Composable
private fun AccountBadge(
fun AccountBadge(
text: String,
modifier: Modifier = Modifier,
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.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)
}

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
}