mirror of
https://github.com/openMF/mobile-wallet.git
synced 2026-02-06 09:37:24 +00:00
feat(beneficiary): Add beneficiary list screen (#1934)
This commit is contained in:
parent
ebb59713c2
commit
9a127fd724
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user