Coverage Summary for Class: AccountDetailsScreenKt (com.georgeci.moneysurfer.feature.account.details)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| AccountDetailsScreenKt |
76%
(19/25)
|
27.1%
(32/118)
|
84.2%
(160/190)
|
68.4%
(1749/2557)
|
| AccountDetailsScreenKt$AccountDetailsContent$4$1$1$8$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| AccountDetailsScreenKt$AccountDetailsContent$4$1$1$8$2 |
100%
(1/1)
|
100%
(6/6)
|
100%
(15/15)
|
100%
(151/151)
|
| AccountDetailsScreenKt$AccountDetailsContent$4$1$1$8$2$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| AccountDetailsScreenKt$AccountDetailsContent$lambda$3$1$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| AccountDetailsScreenKt$AccountDetailsContent$lambda$3$1$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| AccountDetailsScreenKt$AccountDetailsContent$lambda$3$1$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| AccountDetailsScreenKt$AccountDetailsContent$lambda$3$1$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| AccountDetailsScreenKt$AccountDetailsScreen$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| AccountDetailsScreenKt$AccountDetailsScreen$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| AccountDetailsScreenKt$AccountDetailsScreen$3$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| AccountDetailsScreenKt$AccountDetailsScreen$4$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| AccountDetailsScreenKt$WhenMappings |
|
| Total |
55.6%
(20/36)
|
30.6%
(38/124)
|
82.9%
(175/211)
|
69.6%
(1900/2730)
|
package com.georgeci.moneysurfer.feature.account.details
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.AccountExtraDetail
import com.georgeci.moneysurfer.domain.model.AccountExtraDetailKey
import com.georgeci.moneysurfer.domain.primitives.AccountId
import com.georgeci.moneysurfer.domain.primitives.AccountType
import com.georgeci.moneysurfer.domain.primitives.TransactionId
import com.georgeci.moneysurfer.feature.account.extraDetailLabel
import com.georgeci.moneysurfer.feature.account.generated.resources.Res
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_add_transaction
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_chart_title
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_edit_content_description
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_extra_details_section
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_filter_all
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_filter_expenses
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_filter_income
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_in_this_month
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_out_this_month
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_title
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_transactions_empty
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_transactions_section
import com.georgeci.moneysurfer.feature.account.generated.resources.account_details_transactions_see_all
import com.georgeci.moneysurfer.feature.account.icon
import com.georgeci.moneysurfer.feature.account.isMonospaceExtraDetail
import com.georgeci.moneysurfer.feature.account.labelRes
import com.georgeci.moneysurfer.uikit.components.SurferSkeletonRow
import com.georgeci.moneysurfer.uikit.components.account.SurferAccountDetailsHeroCard
import com.georgeci.moneysurfer.uikit.components.account.SurferAccountStatCard
import com.georgeci.moneysurfer.uikit.components.account.SurferBalanceChartCard
import com.georgeci.moneysurfer.uikit.components.base.SurferFilterChipRow
import com.georgeci.moneysurfer.uikit.components.base.SurferPaneAction
import com.georgeci.moneysurfer.uikit.components.base.SurferPaneScaffold
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbarAction
import com.georgeci.moneysurfer.uikit.components.transaction.SurferSwipeToDeleteTransaction
import com.georgeci.moneysurfer.uikit.components.transaction.SurferTransactionLine
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.HandleSideEffect
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/** Stable selectors for the account details screen — see docs/testing/testing-strategy.md. */
object AccountDetailsTestTags {
const val Root = "accountDetails:root"
const val SeeAll = "accountDetails:seeAll"
/** Transaction-type filter chip, suffixed with the lowercased [TransactionFilter]. */
const val FilterPrefix = "accountDetails:filter:"
}
@Composable
fun AccountDetailsScreen(
accountId: AccountId,
onNavigateBack: () -> Unit,
onNavigateToTransactionCreation: (accountId: AccountId) -> Unit = {},
onNavigateToTransactionDetails: (transactionId: TransactionId) -> Unit = {},
onNavigateToAccountEdit: (accountId: AccountId) -> Unit = {},
onNavigateToTransactionsList: (accountId: AccountId) -> Unit = {},
viewModel: AccountDetailsViewModel = koinViewModel(key = accountId.value) { parametersOf(accountId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
AccountDetailsEffect.NavigateBack -> onNavigateBack()
is AccountDetailsEffect.NavigateToTransactionDetails -> onNavigateToTransactionDetails(effect.transactionId)
AccountDetailsEffect.NavigateToTransactionCreation -> onNavigateToTransactionCreation(accountId)
is AccountDetailsEffect.NavigateToAccountEdit -> onNavigateToAccountEdit(effect.accountId)
is AccountDetailsEffect.NavigateToTransactionsList -> onNavigateToTransactionsList(effect.accountId)
}
}
when (val current = state) {
is AccountDetailsState.Loading -> AccountDetailsLoading(onEvent = viewModel::onEvent)
is AccountDetailsState.Content -> AccountDetailsContent(
state = current,
onEvent = viewModel::onEvent,
)
}
}
@Composable
private fun AccountDetailsLoading(onEvent: (AccountDetailsEvent) -> Unit) {
SurferPaneScaffold(
title = stringResource(Res.string.account_details_title),
onBack = { onEvent(AccountDetailsEvent.OnBackClick) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(padding)
.padding(horizontal = AppTheme.spacing.default, vertical = AppTheme.spacing.medium),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
repeat(SKELETON_ROWS) {
SurferSkeletonRow()
}
}
}
}
private const val SKELETON_ROWS = 5
/**
* The account, drawn from a state it is handed rather than one it resolves.
*
* `internal` for the Roborazzi captures in `androidHostTest`, which mount it with a state of their
* own — see docs/testing/screenshot-tests.md.
*/
@Composable
internal fun AccountDetailsContent(
state: AccountDetailsState.Content,
onEvent: (AccountDetailsEvent) -> Unit,
) {
SurferPaneScaffold(
title = stringResource(Res.string.account_details_title),
modifier = Modifier
.testTag(AccountDetailsTestTags.Root)
.surferTestTagAsId(),
onBack = { onEvent(AccountDetailsEvent.OnBackClick) },
actions = {
SurferToolbarAction(
icon = SurferIcons.Edit,
contentDescription = stringResource(Res.string.account_details_edit_content_description),
onClick = { onEvent(AccountDetailsEvent.OnEditClick) },
)
},
primaryAction = SurferPaneAction(
label = stringResource(Res.string.account_details_add_transaction),
onClick = { onEvent(AccountDetailsEvent.OnAddTransactionClick) },
),
) { padding ->
val filtered = remember(state.filter, state.transactions) {
when (state.filter) {
TransactionFilter.All -> state.transactions
TransactionFilter.Expenses -> state.transactions.filter { it.isExpense }
TransactionFilter.Income -> state.transactions.filterNot { it.isExpense }
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding()),
contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
) {
item {
SurferAccountDetailsHeroCard(
// Falls back to the generic wallet only while the type is still loading.
icon = state.type?.icon() ?: SurferIcons.Wallet,
name = state.name,
// The mockup's meta line is the account kind ("CURRENT · •• 4021"); the
// last-4 half needs a field the model does not have yet, so only the kind
// shows — still closer than the currency code that used to sit here.
subtitle = state.type?.let { stringResource(it.labelRes()).uppercase() }.orEmpty(),
formattedBalance = state.formattedBalance.ifBlank { "€0.00" },
modifier = Modifier
.padding(horizontal = AppTheme.spacing.default)
.padding(bottom = AppTheme.spacing.large),
)
}
item { QuickStats(state) }
item {
SurferBalanceChartCard(
title = stringResource(Res.string.account_details_chart_title),
delta = state.chart.formattedDelta,
deltaColor = if (state.chart.isDeltaNegative) {
AppTheme.materialColors.error
} else {
AppTheme.semanticColors.income
},
points = state.chart.points,
modifier = Modifier
.padding(horizontal = AppTheme.spacing.default)
.padding(bottom = AppTheme.spacing.medium),
)
}
if (state.extraDetails.isNotEmpty()) {
item { ExtraDetailsSection(details = state.extraDetails) }
}
item {
FilterChips(
selected = state.filter,
onSelect = { onEvent(AccountDetailsEvent.OnFilterChanged(it)) },
)
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = AppTheme.spacing.large,
end = AppTheme.spacing.large,
top = AppTheme.spacing.medium,
bottom = AppTheme.spacing.xSmall,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.account_details_transactions_section),
style = AppTheme.typography.labelLarge,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(Res.string.account_details_transactions_see_all),
style = AppTheme.typography.labelLarge,
color = AppTheme.materialColors.primary,
modifier = Modifier
.testTag(AccountDetailsTestTags.SeeAll)
.clickable {
onEvent(AccountDetailsEvent.OnSeeAllTransactionsClick)
},
)
}
}
if (filtered.isEmpty()) {
item {
Text(
text = stringResource(Res.string.account_details_transactions_empty),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.padding(AppTheme.spacing.large),
)
}
} else {
items(filtered, key = { it.id.value }) { transaction ->
val incomeColor = AppTheme.semanticColors.income
// The gap below the row belongs to the swipe wrapper and the inset to the line
// — see TransactionRow in the transaction feature for why they cannot swap.
SurferSwipeToDeleteTransaction(
transactionTitle = transaction.title,
onDelete = {
onEvent(AccountDetailsEvent.OnDeleteTransaction(transaction.id))
},
modifier = Modifier.padding(bottom = AppTheme.spacing.small),
isTransfer = transaction.isTransfer,
isSplit = transaction.isSplitLeg,
) {
SurferTransactionLine(
icon = if (transaction.isExpense) SurferIcons.Receipt else SurferIcons.Wallet,
title = transaction.title,
formattedAmount = transaction.formattedAmount,
categoryHueSeed = transaction.categoryHueSeed,
amountColor = if (transaction.isExpense) {
AppTheme.materialColors.onSurface
} else {
incomeColor
},
amountPillBackground = if (transaction.isExpense) {
null
} else {
incomeColor.copy(alpha = 0.18f)
},
modifier = Modifier.padding(horizontal = AppTheme.spacing.default),
onClick = { onEvent(AccountDetailsEvent.OnTransactionClick(transaction.id)) },
)
}
}
}
}
}
}
@Composable
private fun QuickStats(state: AccountDetailsState.Content) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppTheme.spacing.default)
.padding(bottom = AppTheme.spacing.default),
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
SurferAccountStatCard(
label = stringResource(Res.string.account_details_in_this_month),
value = state.formattedIncome,
icon = SurferIcons.ArrowDown,
iconTint = AppTheme.semanticColors.income,
modifier = Modifier.weight(1f),
)
SurferAccountStatCard(
label = stringResource(Res.string.account_details_out_this_month),
value = state.formattedExpenses,
icon = SurferIcons.ArrowUp,
iconTint = AppTheme.materialColors.error,
modifier = Modifier.weight(1f),
)
}
}
/**
* The "Extra details" the creation screen collected. Read-only here — editing them means opening
* the edit form, which is the same screen that defined them.
*/
@Composable
private fun ExtraDetailsSection(details: List<AccountExtraDetail>) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppTheme.spacing.large)
.padding(bottom = AppTheme.spacing.medium),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
Text(
text = stringResource(Res.string.account_details_extra_details_section),
style = AppTheme.typography.labelLarge,
color = AppTheme.materialColors.onSurfaceVariant,
)
details.forEach { detail ->
Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.xSmall)) {
Text(
text = extraDetailLabel(detail.key),
style = AppTheme.typography.labelMedium,
color = AppTheme.materialColors.onSurfaceVariant,
)
Text(
text = detail.value,
style = if (isMonospaceExtraDetail(detail.key)) {
AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
} else {
AppTheme.typography.bodyMedium
},
color = AppTheme.materialColors.onSurface,
)
}
}
}
}
@Composable
private fun FilterChips(
selected: TransactionFilter,
onSelect: (TransactionFilter) -> Unit,
) {
SurferFilterChipRow(
modifier = Modifier.padding(horizontal = AppTheme.spacing.default),
options = listOf(TransactionFilter.All, TransactionFilter.Expenses, TransactionFilter.Income),
selected = selected,
label = { filter ->
stringResource(
when (filter) {
TransactionFilter.All -> Res.string.account_details_filter_all
TransactionFilter.Expenses -> Res.string.account_details_filter_expenses
TransactionFilter.Income -> Res.string.account_details_filter_income
},
)
},
onSelect = onSelect,
optionTestTag = { filter -> AccountDetailsTestTags.FilterPrefix + filter.name.lowercase() },
)
}
/** A month of plausible balances so the preview draws the same shape the real series does. */
private val PreviewChartPoints: List<Pair<Float, Float>> = listOf(
2068f, 2042f, 2110f, 2095f, 2180f, 2164f, 2140f, 2210f, 2196f, 2255f,
2231f, 2288f, 2262f, 2240f, 2310f, 2295f, 2352f, 2330f, 2308f, 2374f,
2360f, 2412f, 2390f, 2368f, 2430f, 2415f, 2462f, 2441f, 2470f, 2480f,
).mapIndexed { index, balance -> index.toFloat() to balance }
@Preview
@Composable
private fun AccountDetailsScreenPreview() {
AppTheme {
AccountDetailsContent(
state = AccountDetailsState.Content(
accountId = AccountId("preview-acc-1"),
name = "Everyday",
formattedBalance = "€2,480.32",
type = AccountType.BANK,
formattedIncome = "€3,200.00",
formattedExpenses = "€1,148.49",
chart = AccountBalanceChartUi(
points = PreviewChartPoints,
formattedDelta = "+€412.00",
isDeltaNegative = false,
),
transactions = listOf(
AccountTransactionUi(
id = TransactionId("preview-tx-1"),
title = "Lidl — weekly shop",
formattedAmount = "−€48.20",
isExpense = true,
categoryHueSeed = "preview-cat-1",
),
AccountTransactionUi(
id = TransactionId("preview-tx-2"),
title = "Salary",
formattedAmount = "€3,200.00",
isExpense = false,
categoryHueSeed = "preview-cat-2",
),
AccountTransactionUi(
id = TransactionId("preview-tx-3"),
title = "Coffee",
formattedAmount = "−€3.80",
isExpense = true,
categoryHueSeed = "preview-cat-3",
),
),
filter = TransactionFilter.All,
extraDetails = listOf(
AccountExtraDetail(
key = AccountExtraDetailKey.IBAN.name,
value = "PL61 1090 1014 0000 0712 1981 2874",
),
AccountExtraDetail(key = "Broker code", value = "MS-4417"),
),
),
onEvent = {},
)
}
}