diff --git a/build-logic/convention/src/main/kotlin/org/mifospay/Badging.kt b/build-logic/convention/src/main/kotlin/org/mifospay/Badging.kt index 25c81ca1..f6d270b5 100644 --- a/build-logic/convention/src/main/kotlin/org/mifospay/Badging.kt +++ b/build-logic/convention/src/main/kotlin/org/mifospay/Badging.kt @@ -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(generateBadgingTaskName) { diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index 2893373e..10e022f1 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -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( diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index ebf26fd2..6f7ad4c7 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -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 } diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/beneficiary/Beneficiary.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/beneficiary/Beneficiary.kt index 7f09f88d..74d8e6ff 100644 --- a/core/model/src/commonMain/kotlin/org/mifospay/core/model/beneficiary/Beneficiary.kt +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/beneficiary/Beneficiary.kt @@ -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, diff --git a/feature/accounts/src/commonMain/composeResources/values/strings.xml b/feature/accounts/src/commonMain/composeResources/values/strings.xml index 8aec9f41..a5d08ddc 100644 --- a/feature/accounts/src/commonMain/composeResources/values/strings.xml +++ b/feature/accounts/src/commonMain/composeResources/values/strings.xml @@ -160,4 +160,8 @@ Update Create Saving Account Update Saving Account + + + Skip the form + Scan QR Code \ No newline at end of file diff --git a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryScreen.kt b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryScreen.kt index c9b10291..97368e14 100644 --- a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryScreen.kt +++ b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryScreen.kt @@ -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) }, + ) + } } } } diff --git a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryViewModel.kt b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryViewModel.kt index 72c1e06d..d2a5f744 100644 --- a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryViewModel.kt +++ b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/AddEditBeneficiaryViewModel.kt @@ -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 diff --git a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/BeneficiaryNavigation.kt b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/BeneficiaryNavigation.kt index 0d1cc681..d5166a37 100644 --- a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/BeneficiaryNavigation.kt +++ b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/beneficiary/BeneficiaryNavigation.kt @@ -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, ) } } diff --git a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/benficiaryList/BeneficiaryListScreen.kt b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/benficiaryList/BeneficiaryListScreen.kt new file mode 100644 index 00000000..a4d9d8d7 --- /dev/null +++ b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/benficiaryList/BeneficiaryListScreen.kt @@ -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() }, + ) + } +} diff --git a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/benficiaryList/BeneficiaryListViewModel.kt b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/benficiaryList/BeneficiaryListViewModel.kt new file mode 100644 index 00000000..9cdf2728 --- /dev/null +++ b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/benficiaryList/BeneficiaryListViewModel.kt @@ -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( + 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(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, + ) : 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) : Internal + } +} diff --git a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/di/AccountsModule.kt b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/di/AccountsModule.kt index 74441485..4d8b23e7 100644 --- a/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/di/AccountsModule.kt +++ b/feature/accounts/src/commonMain/kotlin/org/mifospay/feature/accounts/di/AccountsModule.kt @@ -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) } diff --git a/feature/finance/src/commonMain/kotlin/org/mifospay/feature/finance/FinanceScreen.kt b/feature/finance/src/commonMain/kotlin/org/mifospay/feature/finance/FinanceScreen.kt index 2b4198fc..66563d12 100644 --- a/feature/finance/src/commonMain/kotlin/org/mifospay/feature/finance/FinanceScreen.kt +++ b/feature/finance/src/commonMain/kotlin/org/mifospay/feature/finance/FinanceScreen.kt @@ -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, diff --git a/feature/qr/src/commonMain/composeResources/values/strings.xml b/feature/qr/src/commonMain/composeResources/values/strings.xml index 36748386..adab1cb1 100644 --- a/feature/qr/src/commonMain/composeResources/values/strings.xml +++ b/feature/qr/src/commonMain/composeResources/values/strings.xml @@ -17,5 +17,7 @@ Loading Oops An unexpected error occurred. Please try again. - + Please, align QR Code within the frame to make scanning easily detectable. + Potential Warning + 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. \ No newline at end of file diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/QrCodeScanner.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/QrCodeScanner.kt index 088e10b1..639707ba 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/QrCodeScanner.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/QrCodeScanner.kt @@ -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,27 +58,94 @@ fun QrScannerWithPermissions( openSettingsLabel: String = "Open Settings", onScanned: (String) -> Boolean, ) { - QrScannerWithPermissions( - types = types, - modifier = modifier.clipToBounds(), - onScanned = onScanned, - permissionDeniedContent = { permissionState -> - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, + 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, ) { - Text( - modifier = Modifier.padding(KptTheme.spacing.sm), - text = permissionText, + QrScannerWithPermissions( + types = types, + modifier = modifier.clipToBounds(), + onScanned = onScanned, + permissionDeniedContent = { permissionState -> + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(KptTheme.spacing.sm), + text = permissionText, + ) + Button( + onClick = permissionState::goToSettings, + ) { + Text(openSettingsLabel) + } + } + }, ) - Button( - onClick = permissionState::goToSettings, + + MifosCard( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(KptTheme.spacing.sm), + shape = KptTheme.shapes.medium, ) { - Text(openSettingsLabel) + 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, + ) +} diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt index 66513f28..0bdf13e6 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt @@ -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 } } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index cea7f82b..e30daad0 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -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) + _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(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 } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt index 89bbe6b1..98c231df 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt @@ -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, ) } }