feat(beneficiary): Add beneficiary list screen (#1934)

This commit is contained in:
Nagarjuna 2025-09-17 23:53:32 +05:30 committed by GitHub
parent ebb59713c2
commit 9a127fd724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 794 additions and 21 deletions

View File

@ -97,7 +97,7 @@ fun Project.configureBadgingTasks(
// Registers a callback to be called, when a new variant is configured
componentsExtension.onVariants { variant ->
// Registers a new task to verify the app bundle.
val capitalizedVariantName = variant.name.capitalized()
val capitalizedVariantName = variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
val generateBadging =
tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {

View File

@ -18,6 +18,7 @@ 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.benficiaryList.BeneficiaryListScreen
import org.mifospay.feature.accounts.savingsaccount.SavingsAddEditType
import org.mifospay.feature.accounts.savingsaccount.addEditSavingAccountScreen
import org.mifospay.feature.accounts.savingsaccount.details.navigateToSavingAccountDetails
@ -137,6 +138,12 @@ internal fun MifosNavHost(
onAddOrEditBeneficiary = navController::navigateToBeneficiaryAddEdit,
)
},
TabContent(FinanceScreenContents.BENEFICIARIES.name) {
BeneficiaryListScreen(
onAddOrEditBeneficiary = navController::navigateToBeneficiaryAddEdit,
)
},
// TabContent(FinanceScreenContents.CARDS.name) {
// CardsScreen(
// navigateToViewDetail = navController::navigateToCardDetails,
@ -218,6 +225,7 @@ internal fun MifosNavHost(
addEditBeneficiaryScreen(
navigateBack = navController::navigateUp,
navigateToQrReaderScreen = navController::navigateToScanQr,
)
savingAccountDetailRoute(
@ -376,6 +384,17 @@ internal fun MifosNavHost(
},
)
},
navigateToAddBeneficiaryScreen = {
navController.navigateToBeneficiaryAddEdit(
BeneficiaryAddEditType.EditItem(it),
navOptions = navOptions {
popUpTo(SCAN_QR_ROUTE) {
inclusive = true
}
},
)
},
)
merchantTransferScreen(

View File

@ -45,6 +45,7 @@ import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.material.icons.outlined.DeleteOutline
@ -140,4 +141,5 @@ object MifosIcons {
val History = Icons.Default.History
val Filter = Icons.Default.FilterList
val OpenInNew = Icons.AutoMirrored.Filled.OpenInNew
val Warning = Icons.Default.Warning
}

View File

@ -16,8 +16,8 @@ import org.mifospay.core.common.Parcelize
@Serializable
@Parcelize
data class Beneficiary(
val id: Long,
val name: String,
val id: Long = 0L,
val name: String = "",
val officeName: String,
val clientName: String,
val accountType: AccountType,

View File

@ -160,4 +160,8 @@
<string name="feature_accounts_saving_button_update">Update</string>
<string name="feature_accounts_saving_title_create">Create Saving Account</string>
<string name="feature_accounts_saving_title_update">Update Saving Account</string>
<string name="skip_the_form">Skip the form </string>
<string name="scan_qr_code">Scan QR Code</string>
</resources>

View File

@ -9,10 +9,14 @@
*/
package org.mifospay.feature.accounts.beneficiary
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@ -52,6 +56,8 @@ import mobile_wallet.feature.accounts.generated.resources.feature_accounts_benef
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_beneficiary_nickname
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_beneficiary_office_name
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_beneficiary_transfer_limit
import mobile_wallet.feature.accounts.generated.resources.scan_qr_code
import mobile_wallet.feature.accounts.generated.resources.skip_the_form
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.mifospay.core.designsystem.component.BasicDialogState
@ -70,6 +76,7 @@ import template.core.base.designsystem.theme.KptTheme
@Composable
internal fun AddEditBeneficiaryScreen(
navigateBack: () -> Unit,
navigateToQrReaderScreen: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AddEditBeneficiaryViewModel = koinViewModel(),
) {
@ -82,6 +89,9 @@ internal fun AddEditBeneficiaryScreen(
EventsEffect(viewModel) { event ->
when (event) {
is AEBEvent.NavigateBack -> navigateBack.invoke()
is AEBEvent.NavigateToQr -> navigateToQrReaderScreen.invoke()
is AEBEvent.ShowToast -> {
scope.launch {
snackbarHostState.showSnackbar(event.message)
@ -280,6 +290,22 @@ internal fun AddEditBeneficiaryScreenContent(
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(KptTheme.spacing.md))
Row(
horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm),
) {
Text(
text = stringResource(Res.string.skip_the_form),
)
Text(
text = stringResource(Res.string.scan_qr_code),
modifier = Modifier
.clickable { onAction(AEBAction.OnQrScanClicked) },
)
}
}
}
}

View File

@ -148,6 +148,8 @@ internal class AddEditBeneficiaryViewModel(
AEBAction.SaveBeneficiary -> initiateSaveBeneficiary()
AEBAction.OnQrScanClicked -> sendEvent(AEBEvent.NavigateToQr)
is HandleBeneficiaryAddEditResult -> handleBeneficiaryAddEditResult(action)
}
}
@ -311,6 +313,7 @@ internal data class AEBState(
internal sealed interface AEBEvent {
data object NavigateBack : AEBEvent
data object NavigateToQr : AEBEvent
data class ShowToast(val message: String) : AEBEvent
}
@ -321,6 +324,7 @@ internal sealed interface AEBAction {
data class ChangeAccountNumber(val accountNumber: String) : AEBAction
data class ChangeAccountType(val accountType: Int) : AEBAction
data class ChangeTransferLimit(val transferLimit: String) : AEBAction
data object OnQrScanClicked : AEBAction
data object DismissDialog : AEBAction
data object NavigateBack : AEBAction

View File

@ -43,6 +43,7 @@ data class BeneficiaryAddEditArgs(
fun NavGraphBuilder.addEditBeneficiaryScreen(
navigateBack: () -> Unit,
navigateToQrReaderScreen: () -> Unit,
) {
composableWithSlideTransitions(
route = ADD_EDIT_ITEM_ROUTE,
@ -52,6 +53,7 @@ fun NavGraphBuilder.addEditBeneficiaryScreen(
) {
AddEditBeneficiaryScreen(
navigateBack = navigateBack,
navigateToQrReaderScreen = navigateToQrReaderScreen,
)
}
}

View File

@ -0,0 +1,297 @@
/*
* 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.accounts.benficiaryList
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
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.items
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import mobile_wallet.feature.accounts.generated.resources.Res
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_add
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_delete_beneficiary
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_edit_beneficiary
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_error_oops
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_unexpected_error_subtitle
import org.jetbrains.compose.resources.getString
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.BasicDialogState
import org.mifospay.core.designsystem.component.MifosBasicDialog
import org.mifospay.core.designsystem.component.MifosScaffold
import org.mifospay.core.designsystem.icon.MifosIcons
import org.mifospay.core.designsystem.theme.MifosTheme
import org.mifospay.core.model.beneficiary.Beneficiary
import org.mifospay.core.ui.AvatarBox
import org.mifospay.core.ui.EmptyContentScreen
import org.mifospay.core.ui.MifosProgressIndicator
import org.mifospay.core.ui.utils.EventsEffect
import org.mifospay.feature.accounts.beneficiary.BeneficiaryAddEditType
import template.core.base.designsystem.theme.KptTheme
@Composable
fun BeneficiaryListScreen(
onAddOrEditBeneficiary: (BeneficiaryAddEditType) -> Unit,
modifier: Modifier = Modifier,
viewModel: BeneficiaryListViewModel = koinViewModel(),
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val accountState by viewModel.accountState.collectAsStateWithLifecycle()
EventsEffect(viewModel) { event ->
when (event) {
is BeneficiaryListEvent.OnAddOrEditTPTBeneficiary -> onAddOrEditBeneficiary.invoke(event.type)
is BeneficiaryListEvent.ShowToast -> {
scope.launch {
snackbarHostState.showSnackbar(getString(event.message))
}
}
}
}
BeneficiaryListDialog(
dialogState = state.dialogState,
onAction = remember(viewModel) {
{ viewModel.trySendAction(BeneficiaryListAction.DismissDialog) }
},
)
BeneficiaryListScreenContent(
state = accountState,
onAction = remember(viewModel) {
{ viewModel.trySendAction(it) }
},
snackbarHostState = snackbarHostState,
modifier = modifier,
)
}
@Composable
private fun BeneficiaryListDialog(
dialogState: BeneficiaryListState.DialogState?,
onAction: (BeneficiaryListAction) -> Unit,
) {
when (dialogState) {
is BeneficiaryListState.DialogState.DeleteBeneficiary -> MifosBasicDialog(
visibilityState = BasicDialogState.Shown(
title = stringResource(dialogState.title),
message = stringResource(dialogState.message),
),
onConfirm = dialogState.onConfirm,
onDismissRequest = { onAction(BeneficiaryListAction.DismissDialog) },
)
is BeneficiaryListState.DialogState.Error -> MifosBasicDialog(
visibilityState = BasicDialogState.Shown(
message = dialogState.message,
),
onDismissRequest = { onAction(BeneficiaryListAction.DismissDialog) },
)
else -> Unit
}
}
@Composable
private fun BeneficiaryListScreenContent(
state: BeneficiaryListState.ViewState,
onAction: (BeneficiaryListAction) -> Unit,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
MifosScaffold(
modifier = modifier,
floatingActionButtonPosition = FabPosition.EndOverlay,
snackbarHostState = snackbarHostState,
floatingActionButton = {
AnimatedVisibility(
visible = state.hasFab,
enter = scaleIn(),
exit = scaleOut(),
) {
FloatingActionButton(
onClick = {
onAction(BeneficiaryListAction.AddTPTBeneficiary)
},
) {
Icon(
imageVector = MifosIcons.Add,
stringResource(Res.string.feature_accounts_add),
)
}
}
},
) {
BeneficiariesList(
modifier = Modifier
.padding(it),
state = state,
onAction = onAction,
)
}
}
@Composable
fun BeneficiariesList(
state: BeneficiaryListState.ViewState,
onAction: (BeneficiaryListAction) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(top = KptTheme.spacing.lg),
verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md),
) {
when (state) {
is BeneficiaryListState.ViewState.Loading -> MifosProgressIndicator()
is BeneficiaryListState.ViewState.Content -> {
LazyColumn {
items(
items = state.beneficiaries,
key = { it.id },
) { beneficiary ->
BeneficiaryItem(
beneficiary = beneficiary,
onClickEdit = { onAction(BeneficiaryListAction.EditBeneficiary(it)) },
onClickDelete = { onAction(BeneficiaryListAction.DeleteBeneficiary(it)) },
)
}
}
}
is BeneficiaryListState.ViewState.Error -> {
EmptyContentScreen(
title = stringResource(Res.string.feature_accounts_error_oops),
subTitle = stringResource(Res.string.feature_accounts_unexpected_error_subtitle),
modifier = Modifier,
iconTint = KptTheme.colorScheme.error,
)
}
}
}
}
@Composable
fun BeneficiaryItem(
beneficiary: Beneficiary,
modifier: Modifier = Modifier,
onClickEdit: (Beneficiary) -> Unit,
onClickDelete: (Long) -> Unit,
) {
OutlinedCard(
modifier = modifier.fillMaxWidth(),
shape = KptTheme.shapes.small,
colors = CardDefaults.outlinedCardColors(
containerColor = Color.Transparent,
contentColor = KptTheme.colorScheme.onSurface,
),
) {
ListItem(
headlineContent = {
Text(text = beneficiary.name)
},
supportingContent = {
Text(text = beneficiary.accountNumber)
},
leadingContent = {
AvatarBox(
icon = MifosIcons.AccountCircle,
backgroundColor = KptTheme.colorScheme.tertiaryContainer,
contentColor = KptTheme.colorScheme.tertiary,
)
},
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs),
) {
FilledTonalIconButton(
onClick = {
onClickEdit(beneficiary)
},
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = KptTheme.colorScheme.surfaceContainerHighest,
contentColor = KptTheme.colorScheme.onSurfaceVariant,
),
) {
Icon(
imageVector = MifosIcons.Edit2,
contentDescription = stringResource(Res.string.feature_accounts_edit_beneficiary),
)
}
FilledTonalIconButton(
onClick = {
onClickDelete(beneficiary.id)
},
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = KptTheme.colorScheme.errorContainer,
contentColor = KptTheme.colorScheme.error,
),
) {
Icon(
imageVector = MifosIcons.OutlinedDelete,
contentDescription = stringResource(Res.string.feature_accounts_delete_beneficiary),
)
}
}
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
@Preview
@Composable
fun PreviewBeneficiaryListScreen() {
MifosTheme {
BeneficiaryListScreenContent(
state = BeneficiaryListState.ViewState.Loading,
onAction = { },
modifier = Modifier,
snackbarHostState = remember { SnackbarHostState() },
)
}
}

View File

@ -0,0 +1,207 @@
/*
* 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.accounts.benficiaryList
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import mobile_wallet.feature.accounts.generated.resources.Res
import mobile_wallet.feature.accounts.generated.resources.delete_beneficiary_subtitle
import mobile_wallet.feature.accounts.generated.resources.delete_beneficiary_title
import mobile_wallet.feature.accounts.generated.resources.feature_accounts_beneficiary_deleted
import org.jetbrains.compose.resources.StringResource
import org.mifospay.core.common.DataState
import org.mifospay.core.data.repository.SelfServiceRepository
import org.mifospay.core.datastore.UserPreferencesRepository
import org.mifospay.core.model.beneficiary.Beneficiary
import org.mifospay.core.ui.utils.BaseViewModel
import org.mifospay.feature.accounts.beneficiary.BeneficiaryAddEditType
@OptIn(ExperimentalCoroutinesApi::class)
class BeneficiaryListViewModel(
private val userRepository: UserPreferencesRepository,
private val repository: SelfServiceRepository,
private val json: Json,
) : BaseViewModel<BeneficiaryListState, BeneficiaryListEvent, BeneficiaryListAction>(
initialState = run {
val clientId = requireNotNull(userRepository.clientId.value)
val defaultAccount = userRepository.defaultAccountId.value
BeneficiaryListState(
clientId = clientId,
defaultAccountId = defaultAccount,
)
},
) {
val accountState = mutableStateFlow
.flatMapLatest {
repository.getBeneficiaryList()
}
.mapLatest {
when (it) {
is DataState.Loading -> BeneficiaryListState.ViewState.Loading
is DataState.Error -> BeneficiaryListState.ViewState.Error(it.exception.message.toString())
is DataState.Success -> {
BeneficiaryListState.ViewState.Content(
beneficiaries = it.data,
)
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = BeneficiaryListState.ViewState.Loading,
)
override fun handleAction(action: BeneficiaryListAction) {
when (action) {
is BeneficiaryListAction.AddTPTBeneficiary -> {
sendEvent(BeneficiaryListEvent.OnAddOrEditTPTBeneficiary(BeneficiaryAddEditType.AddItem))
}
is BeneficiaryListAction.EditBeneficiary -> {
viewModelScope.launch {
val beneficiary = json.encodeToString<Beneficiary>(action.beneficiary)
sendEvent(
BeneficiaryListEvent.OnAddOrEditTPTBeneficiary(
BeneficiaryAddEditType.EditItem(beneficiary),
),
)
}
}
is BeneficiaryListAction.DeleteBeneficiary -> {
mutableStateFlow.update {
it.copy(
dialogState = BeneficiaryListState.DialogState.DeleteBeneficiary(
title = Res.string.delete_beneficiary_title,
message = Res.string.delete_beneficiary_subtitle,
onConfirm = {
trySendAction(BeneficiaryListAction.DeleteBeneficiary(action.beneficiaryId))
},
),
)
}
}
is BeneficiaryListAction.DismissDialog -> {
mutableStateFlow.update {
it.copy(dialogState = null)
}
}
is BeneficiaryListAction.Internal.BeneficiaryDeleteResultReceived ->
handleBeneficiaryDeleteResult(action)
is BeneficiaryListAction.Internal.DeleteBeneficiary -> handleDeleteBeneficiary(action)
}
}
private fun handleDeleteBeneficiary(action: BeneficiaryListAction.Internal.DeleteBeneficiary) {
mutableStateFlow.update { it.copy(dialogState = BeneficiaryListState.DialogState.Loading) }
viewModelScope.launch {
val result = repository.deleteBeneficiary(action.beneficiaryId)
sendAction(BeneficiaryListAction.Internal.BeneficiaryDeleteResultReceived(result))
}
}
private fun handleBeneficiaryDeleteResult(action: BeneficiaryListAction.Internal.BeneficiaryDeleteResultReceived) {
when (action.result) {
is DataState.Success -> {
mutableStateFlow.update {
it.copy(dialogState = null)
}
sendEvent(BeneficiaryListEvent.ShowToast(Res.string.feature_accounts_beneficiary_deleted))
}
is DataState.Error -> {
val message = action.result.exception.message.toString()
mutableStateFlow.update {
it.copy(dialogState = BeneficiaryListState.DialogState.Error(message))
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(dialogState = BeneficiaryListState.DialogState.Loading)
}
}
}
}
}
data class BeneficiaryListState(
val clientId: Long,
val defaultAccountId: Long? = null,
val dialogState: DialogState? = null,
) {
sealed interface DialogState {
data object Loading : DialogState
data class Error(val message: String) : DialogState
data class DeleteBeneficiary(
val title: StringResource,
val message: StringResource,
val onConfirm: () -> Unit,
) : DialogState
}
sealed interface ViewState {
val hasFab: Boolean
val isPullToRefreshEnabled: Boolean
data object Loading : ViewState {
override val hasFab: Boolean get() = false
override val isPullToRefreshEnabled: Boolean get() = false
}
data class Error(val message: String) : ViewState {
override val hasFab: Boolean get() = false
override val isPullToRefreshEnabled: Boolean get() = false
}
data class Content(
val beneficiaries: List<Beneficiary>,
) : ViewState {
override val hasFab: Boolean get() = true
override val isPullToRefreshEnabled: Boolean get() = true
}
}
}
sealed interface BeneficiaryListEvent {
data class OnAddOrEditTPTBeneficiary(val type: BeneficiaryAddEditType) : BeneficiaryListEvent
data class ShowToast(val message: StringResource) : BeneficiaryListEvent
}
sealed interface BeneficiaryListAction {
data object AddTPTBeneficiary : BeneficiaryListAction
data class EditBeneficiary(val beneficiary: Beneficiary) : BeneficiaryListAction
data class DeleteBeneficiary(val beneficiaryId: Long) : BeneficiaryListAction
data object DismissDialog : BeneficiaryListAction
sealed interface Internal : BeneficiaryListAction {
data class DeleteBeneficiary(val beneficiaryId: Long) : Internal
data class BeneficiaryDeleteResultReceived(val result: DataState<String>) : Internal
}
}

View File

@ -14,6 +14,7 @@ import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
import org.mifospay.feature.accounts.AccountViewModel
import org.mifospay.feature.accounts.beneficiary.AddEditBeneficiaryViewModel
import org.mifospay.feature.accounts.benficiaryList.BeneficiaryListViewModel
import org.mifospay.feature.accounts.savingsaccount.AddEditSavingViewModel
import org.mifospay.feature.accounts.savingsaccount.details.SavingAccountDetailViewModel
@ -23,4 +24,5 @@ val AccountsModule = module {
viewModelOf(::AddEditBeneficiaryViewModel)
viewModelOf(::SavingAccountDetailViewModel)
viewModelOf(::AddEditSavingViewModel)
viewModelOf(::BeneficiaryListViewModel)
}

View File

@ -38,6 +38,7 @@ internal fun FinanceRoute(
// https://venus.mifos.community/fineract-provider/api/v1/datatables/kyc_level1_details/2
enum class FinanceScreenContents {
ACCOUNTS,
BENEFICIARIES,
// CARDS,
// MERCHANTS,
// KYC,

View File

@ -17,5 +17,7 @@
<string name="feature_qr_loading">Loading</string>
<string name="feature_qr_oops">Oops</string>
<string name="feature_qr_unexpected_error_subtitle">An unexpected error occurred. Please try again.</string>
<string name="feature_qr_instruction">Please, align QR Code within the frame to make scanning easily detectable.</string>
<string name="feature_qr_warning_title">Potential Warning</string>
<string name="feature_qr_warning_message">If you scan the QR code, it could take you to a phishing website that steals your personal information, like credit card numbers or usernames and passwords. It could also download malware onto your phone and give hackers access to your device.</string>
</resources>

View File

@ -9,15 +9,38 @@
*/
package org.mifospay.feature.qr
import androidx.compose.foundation.border
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.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import mobile_wallet.feature.qr.generated.resources.Res
import mobile_wallet.feature.qr.generated.resources.feature_qr_instruction
import mobile_wallet.feature.qr.generated.resources.feature_qr_warning_message
import mobile_wallet.feature.qr.generated.resources.feature_qr_warning_title
import org.jetbrains.compose.resources.stringResource
import org.mifospay.core.designsystem.component.MifosCard
import org.mifospay.core.designsystem.icon.MifosIcons
import template.core.base.designsystem.theme.KptTheme
@Composable
@ -35,6 +58,35 @@ fun QrScannerWithPermissions(
openSettingsLabel: String = "Open Settings",
onScanned: (String) -> Boolean,
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(KptTheme.spacing.lg)
.padding(top = KptTheme.spacing.lg),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 56.dp + 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.feature_qr_instruction),
textAlign = TextAlign.Center,
color = Color.Black,
)
Spacer(modifier = Modifier.height(KptTheme.spacing.md))
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(KptTheme.shapes.medium)
.drawQrCorners()
.border(1.dp, Color.Transparent, KptTheme.shapes.medium),
contentAlignment = Alignment.Center,
) {
QrScannerWithPermissions(
types = types,
modifier = modifier.clipToBounds(),
@ -56,6 +108,44 @@ fun QrScannerWithPermissions(
}
},
)
MifosCard(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(KptTheme.spacing.sm),
shape = KptTheme.shapes.medium,
) {
Column(
modifier = Modifier.padding(KptTheme.spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = MifosIcons.Warning,
contentDescription = "Warning",
tint = Color.Unspecified,
)
Text(
text = stringResource(Res.string.feature_qr_warning_title),
color = Color.Black,
)
}
Text(
text = stringResource(Res.string.feature_qr_warning_message),
color = Color.Black,
textAlign = TextAlign.Center,
)
}
}
}
}
}
}
@Composable
@ -79,3 +169,72 @@ fun QrScannerWithPermissions(
permissionDeniedContent(permissionState)
}
}
private fun Modifier.drawQrCorners(): Modifier = drawWithContent {
drawContent()
val strokeWidth = 5.dp.toPx()
val lineLength = 40.dp.toPx()
val horizontalPadding = 50.dp.toPx() // for left & right
val verticalPaddingTop = 50.dp.toPx() // for top corners
val verticalPaddingBottom = 300.dp.toPx() // more padding for bottom corners
val color = Color.White
// Top-left
drawLine(
color,
Offset(horizontalPadding, verticalPaddingTop),
Offset(horizontalPadding + lineLength, verticalPaddingTop),
strokeWidth,
)
drawLine(
color,
Offset(horizontalPadding, verticalPaddingTop),
Offset(horizontalPadding, verticalPaddingTop + lineLength),
strokeWidth,
)
// Top-right
drawLine(
color,
Offset(size.width - horizontalPadding, verticalPaddingTop),
Offset(size.width - horizontalPadding - lineLength, verticalPaddingTop),
strokeWidth,
)
drawLine(
color,
Offset(size.width - horizontalPadding, verticalPaddingTop),
Offset(size.width - horizontalPadding, verticalPaddingTop + lineLength),
strokeWidth,
)
// Bottom-left
drawLine(
color,
Offset(horizontalPadding, size.height - verticalPaddingBottom),
Offset(horizontalPadding + lineLength, size.height - verticalPaddingBottom),
strokeWidth,
)
drawLine(
color,
Offset(horizontalPadding, size.height - verticalPaddingBottom),
Offset(horizontalPadding, size.height - verticalPaddingBottom - lineLength),
strokeWidth,
)
// Bottom-right
drawLine(
color,
Offset(size.width - horizontalPadding, size.height - verticalPaddingBottom),
Offset(size.width - horizontalPadding - lineLength, size.height - verticalPaddingBottom),
strokeWidth,
)
drawLine(
color,
Offset(size.width - horizontalPadding, size.height - verticalPaddingBottom),
Offset(size.width - horizontalPadding, size.height - verticalPaddingBottom - lineLength),
strokeWidth,
)
}

View File

@ -30,6 +30,7 @@ import org.mifospay.core.designsystem.component.MifosScaffold
internal fun ScanQrCodeScreen(
navigateBack: () -> Unit,
navigateToSendScreen: (String) -> Unit,
navigateToAddBeneficiaryScreen: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ScanQrViewModel = koinViewModel(),
) {
@ -50,6 +51,10 @@ internal fun ScanQrCodeScreen(
}
}
is ScanQrEvent.OnNavigateToAddBeneficiary -> navigateToAddBeneficiaryScreen.invoke(
(eventFlow as ScanQrEvent.OnNavigateToAddBeneficiary).beneficiary,
)
null -> Unit
}
}

View File

@ -13,7 +13,9 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.json.Json
import org.mifospay.core.data.util.UpiQrCodeProcessor
import org.mifospay.core.model.beneficiary.Beneficiary
class ScanQrViewModel : ViewModel() {
@ -30,15 +32,54 @@ class ScanQrViewModel : ViewModel() {
true
} catch (e: Exception) {
getQrCodeResult(data)
}
}
private fun getQrCodeResult(data: String): Boolean {
val trimmedData = data.trim()
println("scanned $data")
if (!trimmedData.startsWith("{") || !trimmedData.endsWith("}")) {
_eventFlow.update {
ScanQrEvent.ShowToast("Scan a Valid Payment QR Code")
ScanQrEvent.ShowToast("Scan a Valid QR Code")
}
return false
}
return try {
val beneficiary = parseBeneficiaryFromJson(trimmedData)
if (beneficiary != null) {
val beneficiaryString = Json.encodeToString<Beneficiary>(beneficiary)
_eventFlow.update {
ScanQrEvent.OnNavigateToAddBeneficiary(beneficiaryString)
}
true
} else {
_eventFlow.update {
ScanQrEvent.ShowToast("Scan a Valid QR Code")
}
false
}
} catch (_: Exception) {
_eventFlow.update {
ScanQrEvent.ShowToast("Scan a Valid QR Code")
}
false
}
}
private fun parseBeneficiaryFromJson(jsonString: String): Beneficiary? {
return try {
Json.decodeFromString<Beneficiary>(jsonString)
} catch (e: Exception) {
null
}
}
}
sealed interface ScanQrEvent {
data class OnNavigateToSendScreen(val data: String) : ScanQrEvent
data class OnNavigateToAddBeneficiary(val beneficiary: String) : ScanQrEvent
data class ShowToast(val message: String) : ScanQrEvent
}

View File

@ -23,11 +23,13 @@ fun NavController.navigateToScanQr(navOptions: NavOptions? = null) =
fun NavGraphBuilder.scanQrScreen(
navigateBack: () -> Unit,
navigateToSendScreen: (String) -> Unit,
navigateToAddBeneficiaryScreen: (String) -> Unit,
) {
composableWithSlideTransitions(route = SCAN_QR_ROUTE) {
ScanQrCodeScreen(
navigateBack = navigateBack,
navigateToSendScreen = navigateToSendScreen,
navigateToAddBeneficiaryScreen = navigateToAddBeneficiaryScreen,
)
}
}