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 5c76521cd..d1981e294 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 @@ -83,6 +83,8 @@ import org.mifospay.feature.payments.PaymentsScreenContents import org.mifospay.feature.payments.RequestScreen import org.mifospay.feature.payments.paymentsScreen import org.mifospay.feature.payments.selectTransferType.SelectTransferTypeScreen +import org.mifospay.feature.pocket.navigation.managePocketDestination +import org.mifospay.feature.pocket.navigation.navigateToManagePocket import org.mifospay.feature.pocket.navigation.navigateToPocketDashboard import org.mifospay.feature.pocket.navigation.pocketDashboardScreen import org.mifospay.feature.profile.navigation.navigateToProfile @@ -347,12 +349,16 @@ internal fun MifosNavHost( pocketDashboardScreen( navigateBack = navController::navigateUp, - navigateToManagePocket = { /* TODO: Implement manage pocket */ }, + navigateToManagePocket = navController::navigateToManagePocket, navigateToLoanAccountDetail = { /* TODO: Implement loan account detail */ }, navigateToShareAccountDetail = { /* TODO: Implement share account detail */ }, navigateToSavingsAccountDetail = navController::navigateToSavingAccountDetails, ) + managePocketDestination( + navigateBack = navController::navigateUp, + ) + settingsScreen( onBackPress = navController::navigateUp, onLogout = onClickLogout, diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImp.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImp.kt index 3d361e71a..6b71e4eec 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImp.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImp.kt @@ -89,6 +89,7 @@ class PocketRepositoryImp( currencyCode = detail?.currency?.code, decimalPlaces = detail?.currency?.decimalPlaces, status = detail?.status?.toAccountStatus(), + currencyDisplaySymbol = detail?.currency?.displaySymbol, ) } AccountType.SAVINGS -> { @@ -100,6 +101,7 @@ class PocketRepositoryImp( currencyCode = detail?.currency?.code, decimalPlaces = detail?.currency?.decimalPlaces, status = detail?.status?.toAccountStatus(), + currencyDisplaySymbol = detail?.currency?.displaySymbol, ) } AccountType.SHARE -> { @@ -108,6 +110,7 @@ class PocketRepositoryImp( var currencyCode: String? var decimalPlaces: Int? var accountStatus: AccountStatus? + var currencyDisplaySymbol: String? try { val shareAccountDetails = dataManager.shareAccountApi @@ -116,6 +119,7 @@ class PocketRepositoryImp( currencyCode = shareAccountDetails.currency?.code decimalPlaces = shareAccountDetails.currency?.decimalPlaces accountStatus = shareAccountDetails.status?.toAccountStatus() + currencyDisplaySymbol = shareAccountDetails.currency?.displaySymbol val approvedShares = shareAccountDetails.summary?.totalApprovedShares ?: 0 val currentMarketPrice = shareAccountDetails.currentMarketPrice ?: 0.0 @@ -126,6 +130,7 @@ class PocketRepositoryImp( currencyCode = detail?.currency?.code decimalPlaces = detail?.currency?.decimalPlaces accountStatus = detail?.status?.toAccountStatus() + currencyDisplaySymbol = detail?.currency?.displaySymbol } DetailedPocketAccount( @@ -135,6 +140,7 @@ class PocketRepositoryImp( currencyCode = currencyCode, decimalPlaces = decimalPlaces, status = accountStatus, + currencyDisplaySymbol = currencyDisplaySymbol, ) } } @@ -146,7 +152,6 @@ class PocketRepositoryImp( ): Flow>> { return networkMonitor.withNetworkCheck( flow { - emit(DataState.Loading) syncPockets(clientId, forceRefresh) detailedPocketCache.collect { state -> @@ -178,7 +183,8 @@ class PocketRepositoryImp( val updatedList = currentState.data.toMutableList() explicitlyAddedAccounts.forEach { explicitlyAddedAccount -> val newlyGeneratedPocket = updatedBasicPockets.find { - it.accountId == explicitlyAddedAccount.pocket.accountId + it.accountId == explicitlyAddedAccount.pocket.accountId && + it.accountType == explicitlyAddedAccount.pocket.accountType } if (newlyGeneratedPocket != null) { val finalAccount = explicitlyAddedAccount.copy(pocket = newlyGeneratedPocket) @@ -216,11 +222,11 @@ class PocketRepositoryImp( val clientAccounts = dataManager.clientsApi.getClientAccounts(clientId) val availableAccounts = mutableListOf() - val alreadyLinkedAccountIds = (detailedPocketCache.value as? DataState.Success) - ?.data?.map { it.pocket.accountId } ?: emptyList() + val alreadyLinkedAccounts = (detailedPocketCache.value as? DataState.Success) + ?.data?.map { Pair(it.pocket.accountId, it.pocket.accountType) } ?: emptyList() clientAccounts.loanAccounts.forEach { loan -> - if (loan.id !in alreadyLinkedAccountIds) { + if (Pair(loan.id ?: 0L, AccountType.LOAN) !in alreadyLinkedAccounts) { availableAccounts.add( LinkableAccount( accountId = loan.id ?: 0L, @@ -229,6 +235,7 @@ class PocketRepositoryImp( accountType = AccountType.LOAN, balance = loan.loanBalance, currencyCode = loan.currency?.code, + currencyDisplaySymbol = loan.currency?.displaySymbol, decimalPlaces = loan.currency?.decimalPlaces, status = loan.status?.toAccountStatus(), ), @@ -237,7 +244,7 @@ class PocketRepositoryImp( } clientAccounts.savingsAccounts.forEach { savings -> - if (savings.id !in alreadyLinkedAccountIds) { + if (Pair(savings.id, AccountType.SAVINGS) !in alreadyLinkedAccounts) { availableAccounts.add( LinkableAccount( accountId = savings.id, @@ -246,6 +253,7 @@ class PocketRepositoryImp( accountType = AccountType.SAVINGS, balance = savings.accountBalance, currencyCode = savings.currency.code, + currencyDisplaySymbol = savings.currency.displaySymbol, decimalPlaces = savings.currency.decimalPlaces, status = savings.status.toAccountStatus(), ), @@ -254,9 +262,10 @@ class PocketRepositoryImp( } clientAccounts.shareAccounts.forEach { share -> - if (share.id !in alreadyLinkedAccountIds) { + if (Pair(share.id ?: 0L, AccountType.SHARE) !in alreadyLinkedAccounts) { var balance = 0.0 var currencyCode: String? = share.currency?.code + var currencyDisplaySymbol: String? = share.currency?.displaySymbol var decimalPlaces: Int? = share.currency?.decimalPlaces try { @@ -267,6 +276,7 @@ class PocketRepositoryImp( balance = approvedShares * currentMarketPrice currencyCode = shareAccountDetails.currency?.code ?: currencyCode + currencyDisplaySymbol = shareAccountDetails.currency?.displaySymbol ?: currencyDisplaySymbol decimalPlaces = shareAccountDetails.currency?.decimalPlaces ?: decimalPlaces } catch (e: Exception) { // do nothing @@ -280,6 +290,7 @@ class PocketRepositoryImp( accountType = AccountType.SHARE, balance = balance, currencyCode = currencyCode, + currencyDisplaySymbol = currencyDisplaySymbol, decimalPlaces = decimalPlaces, status = share.status?.toAccountStatus(), ), diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAccountCard.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAccountCard.kt index b749f0164..8d58e1093 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAccountCard.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAccountCard.kt @@ -48,7 +48,7 @@ fun MifosAccountCard( modifier = modifier .fillMaxWidth() .clickable { onAccountClick(accountId) } - .padding(vertical = 16.dp), + .padding(vertical = 12.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -74,7 +74,7 @@ fun MifosAccountCard( ) { Text( text = accountNumber ?: "", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, ) Text( diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAlertDialog.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAlertDialog.kt new file mode 100644 index 000000000..14e046ecd --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosAlertDialog.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.designsystem.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +@Composable +fun MifosAlertDialog( + title: String, + message: String, + icon: ImageVector, + confirmText: String, + dismissText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = title) }, + text = { Text(text = message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = confirmText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = dismissText) + } + }, + icon = { Icon(icon, contentDescription = null) }, + modifier = modifier, + ) +} diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTab.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTab.kt index f51c7d232..3ca91ea60 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTab.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTab.kt @@ -10,10 +10,8 @@ package org.mifospay.core.designsystem.component import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor @@ -32,7 +30,6 @@ fun MifosTab( modifier: Modifier = Modifier, selectedColor: Color = KptTheme.colorScheme.primary, unselectedColor: Color = KptTheme.colorScheme.primaryContainer, - unselectedBorderColor: Color = Color.Unspecified, ) { Tab( text = { @@ -43,14 +40,8 @@ fun MifosTab( selectedContentColor = contentColorFor(selectedColor), unselectedContentColor = contentColorFor(unselectedColor), modifier = modifier - .height(40.dp) - .padding(horizontal = KptTheme.spacing.xs) - .clip(KptTheme.shapes.extraLarge) + .clip(RoundedCornerShape(25.dp)) .background(if (selected) selectedColor else unselectedColor) - .border( - width = 1.dp, - color = if (!selected) unselectedBorderColor else Color.Transparent, - shape = KptTheme.shapes.extraLarge, - ), + .padding(horizontal = 20.dp), ) } diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTabPager.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTabPager.kt new file mode 100644 index 000000000..c17f3d204 --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/MifosTabPager.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.designsystem.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun MifosTabPager( + pagerState: PagerState, + currentPage: Int, + tabs: List, + setCurrentPage: (Int) -> Unit, + modifier: Modifier = Modifier, + content: @Composable (Int) -> Unit, +) { + Column(modifier = modifier) { + TabRow( + modifier = Modifier.fillMaxWidth(), + selectedTabIndex = currentPage, + indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + modifier = Modifier + .tabIndicatorOffset(tabPositions[currentPage]) + .padding(start = 16.dp, end = 16.dp), + ) + }, + ) { + tabs.forEachIndexed { index, tabTitle -> + Tab( + modifier = Modifier.padding(all = 16.dp), + selected = currentPage == index, + onClick = { setCurrentPage(index) }, + ) { + Text(text = tabTitle) + } + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + pageContent = { page -> + content(page) + }, + ) + } +} diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/pocket/PocketAccount.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/pocket/PocketAccount.kt index 5090d0581..b41dc7186 100644 --- a/core/model/src/commonMain/kotlin/org/mifospay/core/model/pocket/PocketAccount.kt +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/pocket/PocketAccount.kt @@ -24,6 +24,7 @@ data class DetailedPocketAccount( val productName: String?, val balance: Double?, val currencyCode: String?, + val currencyDisplaySymbol: String?, val decimalPlaces: Int?, val status: AccountStatus?, ) @@ -35,6 +36,7 @@ data class LinkableAccount( val accountType: AccountType, val balance: Double?, val currencyCode: String?, + val currencyDisplaySymbol: String?, val decimalPlaces: Int?, val status: AccountStatus?, ) diff --git a/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/ScrollableTabRow.kt b/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/ScrollableTabRow.kt index 21ebed4d1..af50329c8 100644 --- a/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/ScrollableTabRow.kt +++ b/core/ui/src/commonMain/kotlin/org/mifospay/core/ui/ScrollableTabRow.kt @@ -31,7 +31,6 @@ fun MifosScrollableTabRow( containerColor: Color = Color.Transparent, selectedContentColor: Color = KptTheme.colorScheme.primary, unselectedContentColor: Color = KptTheme.colorScheme.surfaceContainerLow, - unselectedBorderColor: Color = KptTheme.colorScheme.primary, edgePadding: Dp = KptTheme.spacing.sm, ) { val scope = rememberCoroutineScope() @@ -50,7 +49,6 @@ fun MifosScrollableTabRow( selected = pagerState.currentPage == index, selectedColor = selectedContentColor, unselectedColor = unselectedContentColor, - unselectedBorderColor = unselectedBorderColor, onClick = { scope.launch { pagerState.animateScrollToPage(index) diff --git a/feature/pocket/src/commonMain/composeResources/values/strings.xml b/feature/pocket/src/commonMain/composeResources/values/strings.xml index 7e196529b..ee479e3ec 100644 --- a/feature/pocket/src/commonMain/composeResources/values/strings.xml +++ b/feature/pocket/src/commonMain/composeResources/values/strings.xml @@ -12,13 +12,35 @@ Pocket Dashboard Total Balance Manage - Savings Accounts - Loan Accounts - Share Accounts - Failed to load pocket accounts. - Unknown Account + SAVINGS ACCOUNTS + LOAN ACCOUNTS + SHARE ACCOUNTS + Total balance + Failed to load pocket accounts Unknown Status + Close + Unknown Account Your Pocket is Empty Group your favorite Savings, Loan, and Share accounts here to track them easily. Link Your First Account + Manage Pocket + Link More Accounts + LINKED ACCOUNTS + No accounts are linked to pocket + Link Accounts + Link Selected (%1$d) + No accounts available to link + Remove linked account + You are removing: + A/c No: %1$s + Remove account? + This will only remove it from Pocket. You can still access the account from the main accounts list. + Link + Remove + Cancel + OK + Something went wrong + Failed to link selected accounts + Failed to remove linked account + Search in %1$s accounts diff --git a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/di/PocketModule.kt b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/di/PocketModule.kt index 41d5e912c..81871eac2 100644 --- a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/di/PocketModule.kt +++ b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/di/PocketModule.kt @@ -11,8 +11,10 @@ package org.mifospay.feature.pocket.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.pocket.viewmodels.ManagePocketViewModel import org.mifospay.feature.pocket.viewmodels.PocketDashboardViewModel val PocketModule = module { viewModelOf(::PocketDashboardViewModel) + viewModelOf(::ManagePocketViewModel) } diff --git a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/navigation/ManagePocketRoute.kt b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/navigation/ManagePocketRoute.kt new file mode 100644 index 000000000..bba769e43 --- /dev/null +++ b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/navigation/ManagePocketRoute.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 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.pocket.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import org.mifospay.feature.pocket.screens.ManagePocketScreen + +const val MANAGE_POCKET_ROUTE = "manage_pocket_route" + +fun NavController.navigateToManagePocket() { + navigate(MANAGE_POCKET_ROUTE) +} + +fun NavGraphBuilder.managePocketDestination( + navigateBack: () -> Unit, +) { + composable(route = MANAGE_POCKET_ROUTE) { + ManagePocketScreen( + navigateBack = navigateBack, + ) + } +} diff --git a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/ManagePocketScreen.kt b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/ManagePocketScreen.kt new file mode 100644 index 000000000..a3658cef1 --- /dev/null +++ b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/ManagePocketScreen.kt @@ -0,0 +1,925 @@ +/* + * Copyright 2026 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.pocket.screens + +/* + * Copyright 2026 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-mobile/blob/master/LICENSE.md + */ + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.pocket.generated.resources.Res +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_action_cancel +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_action_link +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_action_ok +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_action_remove +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_close +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_delink_account_message +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_dialog_error_title +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_link_accounts_title +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_link_more_accounts +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_link_selected +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_linked_accounts +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_manage_title +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_no_available_accounts +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_no_linked_accounts +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_remove_account_detail +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_remove_account_title +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_search_accounts_hint +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_you_are_removing +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.LoadingDialogState +import org.mifospay.core.designsystem.component.MifosAlertDialog +import org.mifospay.core.designsystem.component.MifosBottomSheet +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosLoadingDialog +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTabPager +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.designsystem.theme.MifosTheme +import org.mifospay.core.model.enums.AccountType +import org.mifospay.core.ui.ErrorScreenContent +import org.mifospay.core.ui.MifosProgressIndicator +import org.mifospay.core.ui.utils.EventsEffect +import org.mifospay.feature.pocket.viewmodels.AvailablePocketAccount +import org.mifospay.feature.pocket.viewmodels.ManagePocketAccount +import org.mifospay.feature.pocket.viewmodels.ManagePocketAction +import org.mifospay.feature.pocket.viewmodels.ManagePocketDialogState +import org.mifospay.feature.pocket.viewmodels.ManagePocketEvent +import org.mifospay.feature.pocket.viewmodels.ManagePocketState +import org.mifospay.feature.pocket.viewmodels.ManagePocketUiState +import org.mifospay.feature.pocket.viewmodels.ManagePocketViewModel +import template.core.base.designsystem.theme.KptTheme + +@Composable +internal fun ManagePocketScreen( + navigateBack: () -> Unit, + viewModel: ManagePocketViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + ManagePocketEvent.NavigateBack -> navigateBack.invoke() + } + } + + ManagePocketContent( + state = state, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) + + ManagePocketDialogs( + state = state, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) +} + +@Composable +internal fun ManagePocketContent( + state: ManagePocketState, + onAction: (ManagePocketAction) -> Unit, +) { + MifosScaffold( + backPress = { onAction(ManagePocketAction.NavigateBack) }, + topBarTitle = stringResource(Res.string.feature_pocket_manage_title), + containerColor = KptTheme.colorScheme.background, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + when (state.uiState) { + ManagePocketUiState.Loading -> MifosProgressIndicator() + is ManagePocketUiState.ErrorString -> { + ErrorScreenContent( + title = state.uiState.message, + onClickRetry = { onAction(ManagePocketAction.Retry) }, + ) + } + is ManagePocketUiState.Error -> { + ErrorScreenContent( + title = stringResource(state.uiState.message), + onClickRetry = { onAction(ManagePocketAction.Retry) }, + ) + } + + ManagePocketUiState.Success -> { + Column( + modifier = Modifier + .fillMaxSize() + .let { + if (state.linkedAccounts.isNotEmpty()) { + it.verticalScroll(rememberScrollState()) + } else { + it + } + } + .padding(KptTheme.spacing.md), + ) { + LinkMoreAccountsCard( + onLinkClick = { onAction(ManagePocketAction.OpenLinkAccounts) }, + ) + + if (state.linkedAccounts.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(vertical = KptTheme.spacing.xl), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_pocket_no_linked_accounts), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.secondary, + ) + } + } else { + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + Text( + text = stringResource(Res.string.feature_pocket_linked_accounts), + style = KptTheme.typography.titleSmall, + color = KptTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + state.linkedAccounts.forEach { account -> + LinkedPocketAccountCard( + account = account, + onRemoveClick = { + onAction(ManagePocketAction.OpenDelinkConfirmation(account)) + }, + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun ManagePocketDialogs( + state: ManagePocketState, + onAction: (ManagePocketAction) -> Unit, +) { + when (val dialogState = state.dialogState) { + ManagePocketDialogState.LinkAccounts -> { + MifosBottomSheet( + onDismiss = { onAction(ManagePocketAction.DismissDialog) }, + ) { + LinkAccountsSheet( + state = state, + onAction = onAction, + ) + } + } + + is ManagePocketDialogState.DelinkConfirmation -> { + MifosBottomSheet( + onDismiss = { onAction(ManagePocketAction.DismissDialog) }, + ) { + RemoveLinkedAccountSheet( + account = dialogState.account, + onCancelClick = { onAction(ManagePocketAction.DismissDialog) }, + onRemoveClick = { + onAction(ManagePocketAction.DelinkAccount(dialogState.account)) + }, + ) + } + } + + is ManagePocketDialogState.Error -> { + MifosAlertDialog( + title = stringResource(Res.string.feature_pocket_dialog_error_title), + message = stringResource(dialogState.message), + icon = MifosIcons.Error, + confirmText = stringResource(Res.string.feature_pocket_action_ok), + dismissText = stringResource(Res.string.feature_pocket_action_cancel), + onConfirm = { onAction(ManagePocketAction.DismissDialog) }, + onDismiss = { onAction(ManagePocketAction.DismissDialog) }, + ) + } + + ManagePocketDialogState.Loading -> { + MifosLoadingDialog(visibilityState = LoadingDialogState.Shown) + } + + null -> Unit + } +} + +@Composable +private fun LinkMoreAccountsCard( + onLinkClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + onClick = onLinkClick, + shape = KptTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer.copy(alpha = 0.35f), + contentColor = KptTheme.colorScheme.onSurface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = KptTheme.elevation.level0), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_pocket_link_more_accounts), + modifier = Modifier.weight(1f), + style = KptTheme.typography.titleMedium, + color = KptTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + MifosButton( + onClick = onLinkClick, + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.primary, + contentColor = KptTheme.colorScheme.onPrimary, + ), + content = { + Text( + text = stringResource(Res.string.feature_pocket_action_link), + style = KptTheme.typography.labelLarge, + ) + }, + ) + } + } +} + +@Composable +private fun LinkedPocketAccountCard( + account: ManagePocketAccount, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PocketAccountRow( + account = account, + modifier = modifier, + trailingContent = { + Surface( + modifier = Modifier.size(40.dp), + shape = CircleShape, + color = KptTheme.colorScheme.errorContainer.copy(alpha = 0.62f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onRemoveClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MifosIcons.Delete, + contentDescription = stringResource(Res.string.feature_pocket_action_remove), + tint = KptTheme.colorScheme.error, + modifier = Modifier.size(KptTheme.spacing.lg), + ) + } + } + }, + ) +} + +@Composable +private fun PocketAccountRow( + account: ManagePocketAccount, + modifier: Modifier = Modifier, + trailingContent: @Composable () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PocketAccountIcon(icon = account.accountType.toIcon()) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = account.accountNumber, + style = KptTheme.typography.titleSmall, + color = KptTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = account.name, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + trailingContent() + } +} + +@Composable +private fun LinkAccountsSheet( + state: ManagePocketState, + onAction: (ManagePocketAction) -> Unit, + modifier: Modifier = Modifier, +) { + val tabs = listOf(AccountType.SAVINGS, AccountType.LOAN, AccountType.SHARE) + val selectedTabIndex = tabs.indexOf(state.selectedTab).coerceAtLeast(0) + val pagerState = rememberPagerState( + initialPage = selectedTabIndex, + pageCount = { tabs.size }, + ) + var searchValue by remember { + mutableStateOf(TextFieldValue(state.searchQuery)) + } + + LaunchedEffect(state.searchQuery) { + if (state.searchQuery != searchValue.text) { + searchValue = searchValue.copy(text = state.searchQuery) + } + } + + LaunchedEffect(selectedTabIndex) { + if (pagerState.currentPage != selectedTabIndex) { + pagerState.animateScrollToPage(selectedTabIndex) + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 520.dp, max = 720.dp) + .padding(horizontal = KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_pocket_link_accounts_title), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = KptTheme.spacing.md), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + ManagePocketSearchTextField( + value = searchValue, + onValueChange = { + searchValue = it + onAction(ManagePocketAction.SearchQueryChanged(it.text)) + }, + onSearchDismiss = { + searchValue = TextFieldValue("") + onAction(ManagePocketAction.SearchQueryChanged("")) + }, + hint = stringResource( + Res.string.feature_pocket_search_accounts_hint, + tabs[selectedTabIndex].name.lowercase(), + ), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + MifosTabPager( + modifier = Modifier.fillMaxSize(), + pagerState = pagerState, + currentPage = selectedTabIndex, + tabs = tabs.map { it.name }, + setCurrentPage = { page -> + onAction(ManagePocketAction.TabSelected(tabs[page])) + }, + ) { page -> + val accounts = state.availableAccounts.filter { it.accountType == tabs[page] } + + when { + state.isAvailableAccountsLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + MifosProgressIndicator() + } + } + + accounts.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_pocket_no_available_accounts), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.secondary, + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + items( + items = accounts, + key = { "${it.accountId}_${it.accountType.name}" }, + ) { account -> + SelectablePocketAccountCard( + account = account, + selected = "${account.accountId}_${account.accountType.name}" in state.selectedAccountIdentifiers, + onSelectedChange = { selected -> + onAction( + ManagePocketAction.AccountSelectionChanged( + accountId = account.accountId, + accountType = account.accountType, + selected = selected, + ), + ) + }, + ) + } + } + } + } + } + + if (state.searchQuery.isNotBlank()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(KptTheme.colorScheme.surface.copy(alpha = 0.95f)), + ) { + val searchResults = state.availableAccounts.filter { + it.accountType == state.selectedTab && ( + it.name.contains(state.searchQuery, ignoreCase = true) || + it.accountNumber.contains(state.searchQuery, ignoreCase = true) + ) + } + + if (searchResults.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_pocket_no_available_accounts), + style = KptTheme.typography.bodyMedium, + color = KptTheme.colorScheme.secondary, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + item { Spacer(modifier = Modifier.height(KptTheme.spacing.xs)) } + items(searchResults, key = { "${it.accountId}_${it.accountType.name}" }) { account -> + SelectablePocketAccountCard( + account = account, + selected = "${account.accountId}_${account.accountType.name}" in state.selectedAccountIdentifiers, + onSelectedChange = { selected -> + onAction( + ManagePocketAction.AccountSelectionChanged( + accountId = account.accountId, + accountType = account.accountType, + selected = selected, + ), + ) + }, + ) + } + } + } + } + } + } + + HorizontalDivider(color = KptTheme.colorScheme.outlineVariant) + + MifosButton( + onClick = { onAction(ManagePocketAction.LinkSelectedAccounts) }, + enabled = state.selectedAccountIdentifiers.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = KptTheme.spacing.md), + content = { + Text( + text = stringResource( + Res.string.feature_pocket_link_selected, + state.selectedAccountIdentifiers.size, + ), + style = KptTheme.typography.labelLarge, + ) + }, + ) + } +} + +@Composable +private fun SelectablePocketAccountCard( + account: AvailablePocketAccount, + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onSelectedChange(!selected) } + .padding(vertical = KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + + checked = selected, + onCheckedChange = onSelectedChange, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.xs)) + + PocketAccountIcon(icon = account.accountType.toIcon()) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = account.accountNumber, + style = KptTheme.typography.titleSmall, + color = KptTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = account.name, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun PocketAccountIcon( + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Icon( + imageVector = icon, + contentDescription = null, + tint = KptTheme.colorScheme.onBackground.copy(alpha = 0.3f), + modifier = modifier + .background( + color = KptTheme.colorScheme.background.copy(alpha = 0.5f), + shape = CircleShape, + ) + .padding(KptTheme.spacing.sm), + ) +} + +private fun AccountType.toIcon(): ImageVector = + when (this) { + AccountType.SAVINGS -> MifosIcons.PersonAccounts + AccountType.LOAN -> MifosIcons.CoinMultiple + AccountType.SHARE -> MifosIcons.CoinMultiple + } + +@Composable +private fun ManagePocketSearchTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + onSearchDismiss: () -> Unit, + hint: String, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + + TextField( + modifier = modifier, + value = value, + leadingIcon = { + Icon( + imageVector = MifosIcons.Search, + contentDescription = null, + ) + }, + placeholder = { + Text( + text = hint, + style = KptTheme.typography.bodyLarge, + ) + }, + onValueChange = onValueChange, + textStyle = KptTheme.typography.bodyLarge, + trailingIcon = { + AnimatedVisibility(visible = value.text.isNotEmpty()) { + IconButton(onClick = onSearchDismiss) { + Icon( + imageVector = MifosIcons.Close, + contentDescription = stringResource(Res.string.feature_pocket_close), + ) + } + } + }, + colors = TextFieldDefaults.colors().copy( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.LightGray, + unfocusedIndicatorColor = Color.LightGray, + focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black, + unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black, + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + }, + ), + singleLine = true, + ) +} + +@Composable +private fun RemoveLinkedAccountSheet( + account: ManagePocketAccount, + onCancelClick: () -> Unit, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(KptTheme.spacing.md), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + ) { + Icon( + imageVector = MifosIcons.Warning, + contentDescription = null, + tint = KptTheme.colorScheme.error, + ) + Text( + text = stringResource(Res.string.feature_pocket_remove_account_title), + style = KptTheme.typography.headlineSmall, + color = KptTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + Card( + modifier = Modifier.fillMaxWidth(), + onClick = {}, + enabled = false, + + shape = KptTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer.copy(alpha = 0.35f), + contentColor = KptTheme.colorScheme.onSurface, + ), + ) { + Column( + modifier = Modifier.padding(KptTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = stringResource(Res.string.feature_pocket_you_are_removing), + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.secondary, + ) + Text( + text = account.name, + style = KptTheme.typography.titleMedium, + color = KptTheme.colorScheme.onSurface, + ) + Text( + text = stringResource( + Res.string.feature_pocket_remove_account_detail, + account.accountNumber, + ), + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.secondary, + ) + } + } + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + Text( + text = stringResource(Res.string.feature_pocket_delink_account_message), + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.secondary, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.lg)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + MifosButton( + onClick = onCancelClick, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.surface, + contentColor = KptTheme.colorScheme.primary, + ), + content = { + Text( + text = stringResource(Res.string.feature_pocket_action_cancel), + style = KptTheme.typography.labelLarge, + ) + }, + ) + + MifosButton( + onClick = onRemoveClick, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = KptTheme.colorScheme.error, + contentColor = KptTheme.colorScheme.onError, + ), + content = { + Text( + text = stringResource(Res.string.feature_pocket_action_remove), + style = KptTheme.typography.labelLarge, + ) + }, + ) + } + } +} + +@Preview +@Composable +private fun ManagePocketContentPreview() { + MifosTheme { + ManagePocketContent( + state = ManagePocketState( + linkedAccounts = listOf( + previewPocketAccount(1, AccountType.SAVINGS, "1004859238", "Emergency Fund"), + previewPocketAccount(2, AccountType.LOAN, "3009284756", "Personal Loan"), + previewPocketAccount(3, AccountType.SHARE, "5001129384", "Company Shares"), + ), + uiState = ManagePocketUiState.Success, + ), + onAction = {}, + ) + } +} + +@Preview +@Composable +private fun LinkAccountsSheetContentPreview() { + MifosTheme { + LinkAccountsSheet( + state = ManagePocketState( + availableAccounts = listOf( + previewAvailablePocketAccount(1, AccountType.SAVINGS, "1004859238", "Emergency Fund"), + previewAvailablePocketAccount(2, AccountType.SAVINGS, "1004859239", "Vacation Savings"), + ), + selectedAccountIdentifiers = setOf("1_SAVINGS"), + uiState = ManagePocketUiState.Success, + ), + onAction = {}, + ) + } +} + +@Preview +@Composable +private fun RemoveLinkedAccountSheetContentPreview() { + MifosTheme { + RemoveLinkedAccountSheet( + account = previewPocketAccount(1, AccountType.SAVINGS, "1004859238", "Emergency Fund"), + onCancelClick = {}, + onRemoveClick = {}, + ) + } +} + +private fun previewPocketAccount( + id: Long, + type: AccountType, + number: String, + name: String, +) = ManagePocketAccount( + accountId = id, + mappingId = id, + name = name, + accountNumber = number, + accountType = type, +) + +private fun previewAvailablePocketAccount( + id: Long, + type: AccountType, + number: String, + name: String, +) = AvailablePocketAccount( + accountId = id, + name = name, + accountNumber = number, + accountType = type, +) diff --git a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/PocketDashboardScreen.kt b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/PocketDashboardScreen.kt index 04d0f05fc..dad11a997 100644 --- a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/PocketDashboardScreen.kt +++ b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/screens/PocketDashboardScreen.kt @@ -18,6 +18,7 @@ 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.size import androidx.compose.foundation.rememberScrollState @@ -142,19 +143,19 @@ internal fun PocketDashboardContent( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(24.dp), + .padding(KptTheme.spacing.lg), ) { PocketDashboardCard( totalBalance = state.totalBalance, onManageClick = { onAction(PocketDashboardAction.ManagePocket) }, ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) if (state.savingsAccounts.isNotEmpty()) { PocketSectionHeader(title = Res.string.feature_pocket_dashboard_savings_accounts) - Spacer(modifier = Modifier.height(16.dp)) - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Column(verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md)) { state.savingsAccounts.forEach { account -> MifosAccountCard( accountId = account.accountId, @@ -169,13 +170,13 @@ internal fun PocketDashboardContent( ) } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) } if (state.loanAccounts.isNotEmpty()) { PocketSectionHeader(title = Res.string.feature_pocket_dashboard_loan_accounts) - Spacer(modifier = Modifier.height(16.dp)) - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Column(verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md)) { state.loanAccounts.forEach { account -> MifosAccountCard( accountId = account.accountId, @@ -190,13 +191,13 @@ internal fun PocketDashboardContent( ) } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) } if (state.shareAccounts.isNotEmpty()) { PocketSectionHeader(title = Res.string.feature_pocket_dashboard_share_accounts) - Spacer(modifier = Modifier.height(16.dp)) - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Column(verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md)) { state.shareAccounts.forEach { account -> MifosAccountCard( accountId = account.accountId, @@ -211,7 +212,7 @@ internal fun PocketDashboardContent( ) } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) } } } @@ -228,7 +229,7 @@ internal fun PocketSectionHeader( Text( text = stringResource(title), modifier = modifier.fillMaxWidth(), - style = KptTheme.typography.titleMedium, + style = KptTheme.typography.titleSmall, color = KptTheme.colorScheme.primary, fontWeight = FontWeight.Bold, ) @@ -243,20 +244,20 @@ internal fun PocketDashboardCard( Box( modifier = modifier .clip(KptTheme.shapes.large) - .height(112.dp) + .heightIn(min = 112.dp) .fillMaxWidth() .background(KptTheme.colorScheme.primary), ) { Row( modifier = Modifier .fillMaxSize() - .padding(24.dp), + .padding(KptTheme.spacing.lg), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), ) { Text( text = stringResource(Res.string.feature_pocket_dashboard_total_balance), @@ -266,7 +267,7 @@ internal fun PocketDashboardCard( Text( text = totalBalance, - style = KptTheme.typography.headlineLarge, + style = KptTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = KptTheme.colorScheme.onPrimary, ) @@ -324,7 +325,7 @@ internal fun EmptyPocketContent( Column( modifier = modifier .fillMaxSize() - .padding(24.dp), + .padding(KptTheme.spacing.lg), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -343,7 +344,7 @@ internal fun EmptyPocketContent( } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) Text( text = stringResource(Res.string.feature_pocket_empty_title), @@ -353,7 +354,7 @@ internal fun EmptyPocketContent( textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) Text( text = stringResource(Res.string.feature_pocket_empty_description), @@ -362,7 +363,7 @@ internal fun EmptyPocketContent( textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) MifosButton( onClick = onLinkFirstAccount, @@ -386,7 +387,7 @@ internal fun EmptyPocketContent( internal fun PocketDashboardContentPreview() { PocketDashboardContent( state = PocketDashboardState( - totalBalance = "$ 18,750.00", + totalBalance = "MX$ 10,000.00\n$ 8,750.00", savingsAccounts = listOf( DetailedPocket( accountId = 1L, @@ -408,7 +409,7 @@ internal fun PocketDashboardContentPreview() { accountId = 3L, name = "Personal Loan", accountNumber = "3009284756", - balanceOrStatus = "$ 10,000.00", + balanceOrStatus = "MX$ 10,000.00", status = AccountStatus.ACTIVE, ), DetailedPocket( diff --git a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/ManagePocketViewModel.kt b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/ManagePocketViewModel.kt new file mode 100644 index 000000000..a896ebe1e --- /dev/null +++ b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/ManagePocketViewModel.kt @@ -0,0 +1,400 @@ +/* + * Copyright 2026 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.pocket.viewmodels + +/* + * Copyright 2026 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-mobile/blob/master/LICENSE.md + */ + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mobile_wallet.feature.pocket.generated.resources.Res +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_error_delink_account +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_error_link_accounts +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_error_load_accounts +import mobile_wallet.feature.pocket.generated.resources.feature_pocket_unknown_account +import org.jetbrains.compose.resources.getString +import org.mifospay.core.common.DataState +import org.mifospay.core.data.repository.PocketRepository +import org.mifospay.core.datastore.UserPreferencesRepository +import org.mifospay.core.model.enums.AccountType +import org.mifospay.core.model.payload.PocketLinkPayload +import org.mifospay.core.model.pocket.AccountStatus +import org.mifospay.core.model.pocket.DetailedPocketAccount +import org.mifospay.core.model.pocket.LinkableAccount +import org.mifospay.core.model.pocket.PocketAccount +import org.mifospay.core.ui.utils.BaseViewModel + +internal class ManagePocketViewModel( + private val pocketRepository: PocketRepository, + private val userPreferencesRepository: UserPreferencesRepository, +) : BaseViewModel( + initialState = ManagePocketState( + clientId = requireNotNull(userPreferencesRepository.clientId.value), + ), +) { + private var linkedAccountsJob: Job? = null + private var availableAccountsJob: Job? = null + + init { + loadLinkedAccounts() + } + + private fun updateState(update: (ManagePocketState) -> ManagePocketState) { + mutableStateFlow.update(update) + } + + override fun handleAction(action: ManagePocketAction) { + when (action) { + ManagePocketAction.NavigateBack -> sendEvent(ManagePocketEvent.NavigateBack) + ManagePocketAction.Retry -> loadLinkedAccounts(forceRefresh = true) + ManagePocketAction.OpenLinkAccounts -> openLinkAccounts() + ManagePocketAction.DismissDialog -> dismissDialog() + is ManagePocketAction.OpenDelinkConfirmation -> openDelinkConfirmation(action.account) + is ManagePocketAction.TabSelected -> updateState { it.copy(selectedTab = action.accountType) } + is ManagePocketAction.SearchQueryChanged -> updateState { it.copy(searchQuery = action.query) } + is ManagePocketAction.AccountSelectionChanged -> updateSelectedAccount( + accountId = action.accountId, + accountType = action.accountType, + selected = action.selected, + ) + ManagePocketAction.LinkSelectedAccounts -> linkSelectedAccounts() + is ManagePocketAction.DelinkAccount -> delinkAccount(action.account) + is ManagePocketAction.Internal.ReceiveLinkedAccounts -> handleLinkedAccounts(action.dataState, action.unknownAccount) + is ManagePocketAction.Internal.ReceiveAvailableAccounts -> handleAvailableAccounts(action.dataState, action.unknownAccount) + } + } + + private fun loadLinkedAccounts(forceRefresh: Boolean = false) { + linkedAccountsJob?.cancel() + linkedAccountsJob = viewModelScope.launch { + val unknownAccount = getString(Res.string.feature_pocket_unknown_account) + pocketRepository.getDetailedPocketAccounts(state.clientId, forceRefresh) + .collect { dataState -> + trySendAction(ManagePocketAction.Internal.ReceiveLinkedAccounts(dataState, unknownAccount)) + } + } + } + + private fun loadAvailableAccounts() { + availableAccountsJob?.cancel() + availableAccountsJob = viewModelScope.launch { + val unknownAccount = getString(Res.string.feature_pocket_unknown_account) + pocketRepository.getAvailableAccountsToLink(state.clientId) + .collect { dataState -> + trySendAction(ManagePocketAction.Internal.ReceiveAvailableAccounts(dataState, unknownAccount)) + } + } + } + + private fun openLinkAccounts() { + updateState { + it.copy( + dialogState = ManagePocketDialogState.LinkAccounts, + ) + } + loadAvailableAccounts() + } + + private fun dismissDialog() { + updateState { + it.copy( + dialogState = null, + ) + } + } + + private fun openDelinkConfirmation(account: ManagePocketAccount) { + updateState { + it.copy(dialogState = ManagePocketDialogState.DelinkConfirmation(account)) + } + } + + private fun updateSelectedAccount(accountId: Long, accountType: AccountType, selected: Boolean) { + updateState { + val identifier = "${accountId}_${accountType.name}" + val updated = if (selected) { + it.selectedAccountIdentifiers + identifier + } else { + it.selectedAccountIdentifiers - identifier + } + + it.copy(selectedAccountIdentifiers = updated) + } + } + + private fun linkSelectedAccounts() { + val accountsToLink = state.availableAccounts.filter { + "${it.accountId}_${it.accountType.name}" in state.selectedAccountIdentifiers + } + + if (accountsToLink.isEmpty()) return + + viewModelScope.launch { + updateState { it.copy(dialogState = ManagePocketDialogState.Loading) } + + val payload = PocketLinkPayload( + accountsDetail = accountsToLink.map { + PocketLinkPayload.AccountDetail( + accountId = it.accountId.toString(), + accountType = it.accountType, + ) + }, + ) + + val explicitAccounts = accountsToLink.map { it.toDetailedPocketAccount() } + + when ( + val dataState = pocketRepository.linkAccounts( + payload = payload, + explicitlyAddedAccounts = explicitAccounts, + clientId = state.clientId, + ) + ) { + is DataState.Success -> { + updateState { + it.copy( + dialogState = null, + selectedAccountIdentifiers = emptySet(), + searchQuery = "", + ) + } + loadLinkedAccounts(forceRefresh = true) + } + + is DataState.Error -> { + updateState { + it.copy( + dialogState = ManagePocketDialogState.Error( + Res.string.feature_pocket_error_link_accounts, + ), + ) + } + } + + DataState.Loading -> Unit + } + } + } + + private fun delinkAccount(account: ManagePocketAccount) { + viewModelScope.launch { + updateState { it.copy(dialogState = ManagePocketDialogState.Loading) } + + when ( + val dataState = pocketRepository.delinkAccounts( + pocketAccountMappingIds = listOf(account.mappingId), + clientId = state.clientId, + ) + ) { + is DataState.Success -> { + updateState { it.copy(dialogState = null) } + loadLinkedAccounts(forceRefresh = true) + } + + is DataState.Error -> { + updateState { + it.copy( + dialogState = ManagePocketDialogState.Error( + Res.string.feature_pocket_error_delink_account, + ), + ) + } + } + + DataState.Loading -> Unit + } + } + } + + private fun handleLinkedAccounts(dataState: DataState>, unknownAccount: String) { + when (dataState) { + DataState.Loading -> { + updateState { it.copy(uiState = ManagePocketUiState.Loading) } + } + + is DataState.Error -> { + updateState { + it.copy( + uiState = ManagePocketUiState.Error(Res.string.feature_pocket_error_load_accounts), + ) + } + } + + is DataState.Success -> { + val linkedAccounts = dataState.data.map { it.toManagePocketAccount(unknownAccount) } + updateState { + it.copy( + linkedAccounts = linkedAccounts, + uiState = ManagePocketUiState.Success, + ) + } + } + } + } + + private fun handleAvailableAccounts(dataState: DataState>, unknownAccount: String) { + when (dataState) { + DataState.Loading -> updateState { + it.copy(isAvailableAccountsLoading = true) + } + + is DataState.Error -> updateState { + it.copy( + isAvailableAccountsLoading = false, + dialogState = ManagePocketDialogState.Error( + Res.string.feature_pocket_error_load_accounts, + ), + ) + } + + is DataState.Success -> { + val availableAccounts = dataState.data.map { account -> + account.toAvailablePocketAccount(unknownAccount) + } + updateState { + it.copy( + availableAccounts = availableAccounts, + isAvailableAccountsLoading = false, + ) + } + } + } + } + + private fun DetailedPocketAccount.toManagePocketAccount(unknownAccount: String): ManagePocketAccount { + return ManagePocketAccount( + accountId = pocket.accountId, + mappingId = pocket.id, + name = productName ?: unknownAccount, + accountNumber = pocket.accountNumber, + accountType = pocket.accountType, + ) + } + + private fun LinkableAccount.toAvailablePocketAccount(unknownAccount: String): AvailablePocketAccount { + return AvailablePocketAccount( + accountId = accountId, + name = productName ?: unknownAccount, + accountNumber = accountNumber.orEmpty(), + accountType = accountType, + balance = balance, + currencyCode = currencyCode, + currencyDisplaySymbol = currencyDisplaySymbol, + decimalPlaces = decimalPlaces, + status = status, + ) + } + + private fun AvailablePocketAccount.toDetailedPocketAccount(): DetailedPocketAccount { + return DetailedPocketAccount( + pocket = PocketAccount( + pocketId = -1, + id = -1, + accountId = accountId, + accountType = accountType, + accountNumber = accountNumber, + ), + productName = name, + balance = balance, + currencyCode = currencyCode, + currencyDisplaySymbol = currencyDisplaySymbol, + decimalPlaces = decimalPlaces, + status = status, + ) + } +} + +internal data class ManagePocketState( + val clientId: Long = 0, + val linkedAccounts: List = emptyList(), + val availableAccounts: List = emptyList(), + val selectedAccountIdentifiers: Set = emptySet(), + val selectedTab: AccountType = AccountType.SAVINGS, + val searchQuery: String = "", + val uiState: ManagePocketUiState = ManagePocketUiState.Loading, + val dialogState: ManagePocketDialogState? = null, + val isAvailableAccountsLoading: Boolean = false, +) + +internal sealed interface ManagePocketUiState { + data object Loading : ManagePocketUiState + data class ErrorString(val message: String) : ManagePocketUiState + data class Error(val message: org.jetbrains.compose.resources.StringResource) : ManagePocketUiState + data object Success : ManagePocketUiState +} + +data class ManagePocketAccount( + val accountId: Long, + val mappingId: Long, + val name: String, + val accountNumber: String, + val accountType: AccountType, +) + +internal data class AvailablePocketAccount( + val accountId: Long, + val name: String, + val accountNumber: String, + val accountType: AccountType, + val balance: Double? = null, + val currencyCode: String? = null, + val currencyDisplaySymbol: String? = null, + val decimalPlaces: Int? = null, + val status: AccountStatus? = null, +) + +internal sealed interface ManagePocketDialogState { + data object LinkAccounts : ManagePocketDialogState + data object Loading : ManagePocketDialogState + data class DelinkConfirmation(val account: ManagePocketAccount) : ManagePocketDialogState + data class Error(val message: org.jetbrains.compose.resources.StringResource) : ManagePocketDialogState +} + +internal sealed interface ManagePocketEvent { + data object NavigateBack : ManagePocketEvent +} + +internal sealed interface ManagePocketAction { + data object NavigateBack : ManagePocketAction + data object Retry : ManagePocketAction + data object OpenLinkAccounts : ManagePocketAction + data object DismissDialog : ManagePocketAction + data object LinkSelectedAccounts : ManagePocketAction + data class OpenDelinkConfirmation(val account: ManagePocketAccount) : ManagePocketAction + data class DelinkAccount(val account: ManagePocketAccount) : ManagePocketAction + data class TabSelected(val accountType: AccountType) : ManagePocketAction + data class SearchQueryChanged(val query: String) : ManagePocketAction + data class AccountSelectionChanged( + val accountId: Long, + val accountType: AccountType, + val selected: Boolean, + ) : ManagePocketAction + + sealed interface Internal : ManagePocketAction { + data class ReceiveLinkedAccounts( + val dataState: DataState>, + val unknownAccount: String, + ) : Internal + + data class ReceiveAvailableAccounts( + val dataState: DataState>, + val unknownAccount: String, + ) : Internal + } +} diff --git a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/PocketDashboardViewModel.kt b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/PocketDashboardViewModel.kt index f6303f5c7..5a0638846 100644 --- a/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/PocketDashboardViewModel.kt +++ b/feature/pocket/src/commonMain/kotlin/org/mifospay/feature/pocket/viewmodels/PocketDashboardViewModel.kt @@ -10,11 +10,7 @@ package org.mifospay.feature.pocket.viewmodels import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import mobile_wallet.feature.pocket.generated.resources.Res @@ -32,7 +28,6 @@ import org.mifospay.core.model.pocket.AccountStatus import org.mifospay.core.model.pocket.DetailedPocketAccount import org.mifospay.core.ui.utils.BaseViewModel -@OptIn(ExperimentalCoroutinesApi::class) internal class PocketDashboardViewModel( private val pocketRepository: PocketRepository, private val userPreferencesRepository: UserPreferencesRepository, @@ -41,26 +36,34 @@ internal class PocketDashboardViewModel( clientId = requireNotNull(userPreferencesRepository.clientId.value), ), ) { - private val refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) + private var loadJob: Job? = null init { - viewModelScope.launch { - refreshTrigger.onStart { emit(false) } - .flatMapLatest { forceRefresh -> - pocketRepository.getDetailedPocketAccounts(state.clientId, forceRefresh) - } - .collectLatest { dataState -> - handleDataState(dataState) - } - } + loadPocketData() } private fun updateState(update: (PocketDashboardState) -> PocketDashboardState) { mutableStateFlow.update(update) } + private fun loadPocketData(forceRefresh: Boolean = false) { + loadJob?.cancel() + + loadJob = viewModelScope.launch { + val unknownStatus = getString(Res.string.feature_pocket_unknown_status) + val unknownAccount = getString(Res.string.feature_pocket_unknown_account) + + val clientId = state.clientId + pocketRepository.getDetailedPocketAccounts(clientId, forceRefresh) + .collect { dataState -> + trySendAction(PocketDashboardAction.Internal.ReceiveAccounts(dataState, unknownStatus, unknownAccount)) + } + } + } + override fun handleAction(action: PocketDashboardAction) { when (action) { + is PocketDashboardAction.Internal.ReceiveAccounts -> handleReceivedAccounts(action.dataState, action.unknownStatus, action.unknownAccount) PocketDashboardAction.NavigateBack -> sendEvent(PocketDashboardEvent.NavigateBack) PocketDashboardAction.ManagePocket -> sendEvent(PocketDashboardEvent.ManagePocket) PocketDashboardAction.LinkFirstAccount -> sendEvent(PocketDashboardEvent.ManagePocket) @@ -80,15 +83,19 @@ internal class PocketDashboardViewModel( private fun refresh() { updateState { it.copy(isRefreshing = true) } - refreshTrigger.tryEmit(true) + loadPocketData(forceRefresh = true) } private fun retry() { updateState { it.copy(uiState = PocketDashboardUiState.Loading) } - refreshTrigger.tryEmit(true) + loadPocketData(forceRefresh = true) } - private suspend fun handleDataState(dataState: DataState>) { + private fun handleReceivedAccounts( + dataState: DataState>, + unknownStatus: String, + unknownAccount: String, + ) { when (dataState) { is DataState.Loading -> { if (!state.isRefreshing) { @@ -108,24 +115,23 @@ internal class PocketDashboardViewModel( is DataState.Success -> { val detailedAccounts = dataState.data - suspend fun mapToUiModel(detailed: DetailedPocketAccount): DetailedPocket { + fun mapToUiModel(detailed: DetailedPocketAccount): DetailedPocket { val balanceStr = if (detailed.status == AccountStatus.ACTIVE) { if (detailed.balance != null) { - CurrencyFormatter.format( - detailed.balance, - detailed.currencyCode, - detailed.decimalPlaces, - ) + val code = detailed.currencyCode.orEmpty() + val displaySymbol = detailed.currencyDisplaySymbol.orEmpty() + val formattedNum = CurrencyFormatter.format(detailed.balance, detailed.decimalPlaces) + if (code.isNotEmpty()) "$code $displaySymbol$formattedNum" else "$displaySymbol$formattedNum" } else { "" } } else { - detailed.status?.name ?: getString(Res.string.feature_pocket_unknown_status) + detailed.status?.name ?: unknownStatus } return DetailedPocket( accountId = detailed.pocket.accountId, - name = detailed.productName ?: getString(Res.string.feature_pocket_unknown_account), + name = detailed.productName ?: unknownAccount, accountNumber = detailed.pocket.accountNumber, balanceOrStatus = balanceStr, status = detailed.status ?: AccountStatus.UNKNOWN, @@ -143,16 +149,30 @@ internal class PocketDashboardViewModel( } } - val totalSum = detailedAccounts - .filter { it.status == AccountStatus.ACTIVE && it.balance != null } - .sumOf { it.balance ?: 0.0 } + val balancesByCurrency = detailedAccounts + .filter { it.status == AccountStatus.ACTIVE && it.balance != null && it.currencyCode != null } + .groupBy { it.currencyCode!! } + .map { (currencyCode, accounts) -> + val sum = accounts.sumOf { it.balance ?: 0.0 } + val decimalPlaces = accounts.first().decimalPlaces + val displaySymbol = accounts.first().currencyDisplaySymbol.orEmpty() + val formattedNum = CurrencyFormatter.format(sum, decimalPlaces) + if (currencyCode.isNotEmpty()) "$currencyCode $displaySymbol$formattedNum" else "$displaySymbol$formattedNum" + } - val sampleAccount = detailedAccounts.firstOrNull { it.currencyCode != null } - val formattedTotal = CurrencyFormatter.format( - totalSum, - sampleAccount?.currencyCode, - sampleAccount?.decimalPlaces, - ) + val formattedTotal = if (balancesByCurrency.isNotEmpty()) { + balancesByCurrency.joinToString("\n") + } else { + val sampleAccount = detailedAccounts.firstOrNull { it.currencyCode != null } + if (sampleAccount != null) { + val code = sampleAccount.currencyCode.orEmpty() + val displaySymbol = sampleAccount.currencyDisplaySymbol.orEmpty() + val formattedNum = CurrencyFormatter.format(0.0, sampleAccount.decimalPlaces) + if (code.isNotEmpty()) "$code $displaySymbol$formattedNum" else "$displaySymbol$formattedNum" + } else { + "0.00" + } + } if (loanList.isEmpty() && shareList.isEmpty() && savingsList.isEmpty()) { updateState { @@ -219,4 +239,12 @@ internal sealed interface PocketDashboardAction { data object LinkFirstAccount : PocketDashboardAction data object Refresh : PocketDashboardAction data object Retry : PocketDashboardAction + + sealed interface Internal : PocketDashboardAction { + data class ReceiveAccounts( + val dataState: DataState>, + val unknownStatus: String, + val unknownAccount: String, + ) : Internal + } }