Coverage Summary for Class: AccountsManageScreenKt (com.georgeci.moneysurfer.feature.account.manage)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| AccountsManageScreenKt |
70%
(21/30)
|
35.8%
(48/134)
|
82.3%
(158/192)
|
65.4%
(1293/1976)
|
| AccountsManageScreenKt$AccountsManageContent$3$2$1$4$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| AccountsManageScreenKt$AccountsManageContent$3$2$1$4$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| AccountsManageScreenKt$AccountsManageContent$3$2$1$4$3$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| AccountsManageScreenKt$AccountsManageContent$3$2$1$6$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$5 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$6 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$7 |
0%
(0/1)
|
|
| AccountsManageScreenKt$AccountsManageContent$lambda$2$4$0$$inlined$items$default$8 |
0%
(0/1)
|
|
| Total |
50%
(21/42)
|
35.8%
(48/134)
|
80.6%
(158/196)
|
64%
(1293/2020)
|
package com.georgeci.moneysurfer.feature.account.manage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.primitives.AccountId
import com.georgeci.moneysurfer.feature.account.generated.resources.Res
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_active_header
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_add_account
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_archive
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_archived_at
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_archived_footnote
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_archived_header
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_archived_hint
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_done
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_drag_to_reorder
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_edit
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_empty
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_remove
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_restore
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_stat_active_count
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_stat_archived_count
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_stat_total
import com.georgeci.moneysurfer.feature.account.generated.resources.accounts_manage_title
import com.georgeci.moneysurfer.feature.account.generated.resources.month_short
import com.georgeci.moneysurfer.feature.account.icon
import com.georgeci.moneysurfer.feature.account.labelRes
import com.georgeci.moneysurfer.uikit.components.SurferEmptyState
import com.georgeci.moneysurfer.uikit.components.SurferSkeletonRow
import com.georgeci.moneysurfer.uikit.components.account.SurferAccountManageCard
import com.georgeci.moneysurfer.uikit.components.account.SurferArchivedAccountCard
import com.georgeci.moneysurfer.uikit.components.account.SurferTotalBalanceCard
import com.georgeci.moneysurfer.uikit.components.base.SurferAddFab
import com.georgeci.moneysurfer.uikit.components.base.SurferDragHandle
import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeader
import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeaderHint
import com.georgeci.moneysurfer.uikit.components.base.SurferSwipeAction
import com.georgeci.moneysurfer.uikit.components.base.SurferSwipeRevealRow
import com.georgeci.moneysurfer.uikit.components.base.SurferToggleChipButton
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
import com.georgeci.moneysurfer.uikit.components.base.rememberSurferReorderState
import com.georgeci.moneysurfer.uikit.components.base.surferReorderHandle
import com.georgeci.moneysurfer.uikit.components.base.surferReorderableItem
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.HandleSideEffect
import kotlinx.datetime.number
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
/**
* This screen owns the only add-account affordance that survives a non-empty list — the
* dashboard's own CTA is empty-state-only — so E2E flows that create a second account come
* through here.
*/
object AccountsManageTestTags {
const val Root = "accountsManage:root"
const val Add = "accountsManage:add"
}
@Composable
fun AccountsManageScreen(
onNavigateBack: () -> Unit,
onNavigateToAccountCreation: () -> Unit,
onNavigateToAccountEdit: (AccountId) -> Unit,
onNavigateToAccountDetails: (AccountId) -> Unit,
viewModel: AccountsManageViewModel = koinViewModel(),
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
AccountsManageEffect.NavigateBack -> onNavigateBack()
AccountsManageEffect.NavigateToAccountCreation -> onNavigateToAccountCreation()
is AccountsManageEffect.NavigateToAccountDetails -> onNavigateToAccountDetails(effect.accountId)
is AccountsManageEffect.NavigateToAccountEdit -> onNavigateToAccountEdit(effect.accountId)
}
}
when (val current = state) {
AccountsManageState.Loading -> AccountsManageLoading(onEvent = viewModel::onEvent)
is AccountsManageState.Content -> AccountsManageContent(
state = current,
onEvent = viewModel::onEvent,
)
}
}
@Composable
private fun AccountsManageLoading(onEvent: (AccountsManageEvent) -> Unit) {
Scaffold(
modifier = Modifier.surferSafeInsets(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(Res.string.accounts_manage_title),
onBack = { onEvent(AccountsManageEvent.OnBackClick) },
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(padding)
.padding(horizontal = AppTheme.spacing.default, vertical = AppTheme.spacing.medium),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
repeat(ACCOUNTS_SKELETON_ROWS) {
SurferSkeletonRow()
}
}
}
}
private const val ACCOUNTS_SKELETON_ROWS = 5
/**
* The list, 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 AccountsManageContent(
state: AccountsManageState.Content,
onEvent: (AccountsManageEvent) -> Unit,
) {
val hasContent = state.activeAccounts.isNotEmpty() || state.archivedAccounts.isNotEmpty()
Scaffold(
modifier = Modifier
.surferSafeInsets()
.testTag(AccountsManageTestTags.Root)
.surferTestTagAsId(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(Res.string.accounts_manage_title),
onBack = { onEvent(AccountsManageEvent.OnBackClick) },
actions = {
SurferToggleChipButton(
label = stringResource(
if (state.isEditing) {
Res.string.accounts_manage_done
} else {
Res.string.accounts_manage_edit
},
),
icon = if (state.isEditing) SurferIcons.Check else SurferIcons.Edit,
active = state.isEditing,
onClick = { onEvent(AccountsManageEvent.OnToggleEditMode) },
modifier = Modifier.padding(end = AppTheme.spacing.small),
)
},
)
},
floatingActionButton = {
SurferAddFab(
label = stringResource(Res.string.accounts_manage_add_account),
onClick = { onEvent(AccountsManageEvent.OnAddAccountClick) },
modifier = Modifier.testTag(AccountsManageTestTags.Add),
)
},
) { padding ->
if (!hasContent) {
Box(
modifier = Modifier.fillMaxSize().surferContentContainer().padding(padding),
contentAlignment = Alignment.Center,
) {
SurferEmptyState(
title = stringResource(Res.string.accounts_manage_empty),
icon = SurferIcons.Wallet,
)
}
return@Scaffold
}
val listState = rememberLazyListState()
val reorderState = rememberSurferReorderState(
listState = listState,
keys = state.activeAccounts.map { activeRowKey(it.id) },
onMove = { from, to ->
val fromId = state.activeAccounts.firstOrNull { activeRowKey(it.id) == from }?.id
val toId = state.activeAccounts.firstOrNull { activeRowKey(it.id) == to }?.id
if (fromId != null && toId != null) {
onEvent(AccountsManageEvent.OnAccountMove(from = fromId, to = toId))
}
},
onMoveEnd = { onEvent(AccountsManageEvent.OnAccountMoveEnd) },
)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding()),
contentPadding = PaddingValues(
top = AppTheme.spacing.xSmall,
bottom = padding.calculateBottomPadding() + 96.dp,
),
) {
item(key = "aggregate") {
SurferTotalBalanceCard(
label = stringResource(Res.string.accounts_manage_stat_total),
formattedTotal = state.formattedTotal,
activeChipText = pluralStringResource(
Res.plurals.accounts_manage_stat_active_count,
state.activeAccounts.size,
state.activeAccounts.size,
),
archivedChipText = state.archivedAccounts.size
.takeIf { it > 0 }
?.let {
pluralStringResource(Res.plurals.accounts_manage_stat_archived_count, it, it)
},
otherCurrencyChipTexts = state.otherCurrencyTotals,
modifier = Modifier.padding(
horizontal = AppTheme.spacing.default,
vertical = AppTheme.spacing.xSmall,
),
)
}
item(key = "active-header") {
SurferSectionHeader(
title = stringResource(Res.string.accounts_manage_active_header),
trailing = if (state.isEditing) {
{
SurferSectionHeaderHint(
text = stringResource(Res.string.accounts_manage_drag_to_reorder),
)
}
} else {
null
},
modifier = Modifier
.padding(horizontal = AppTheme.spacing.large)
.padding(top = AppTheme.spacing.small, bottom = 6.dp),
)
}
items(state.activeAccounts, key = { activeRowKey(it.id) }) { account ->
val rowKey = activeRowKey(account.id)
ActiveAccountRow(
account = account,
editing = state.isEditing,
archiveLabel = stringResource(Res.string.accounts_manage_archive),
deleteLabel = stringResource(Res.string.accounts_manage_remove),
onClick = { onEvent(AccountsManageEvent.OnAccountClick(account.id)) },
onArchiveClick = { onEvent(AccountsManageEvent.OnArchiveAccountClick(account.id)) },
onRemoveClick = { onEvent(AccountsManageEvent.OnRemoveAccountClick(account.id)) },
handleModifier = Modifier.surferReorderHandle(reorderState, rowKey),
modifier = surferReorderableItem(reorderState, rowKey)
.padding(horizontal = AppTheme.spacing.default, vertical = 5.dp),
)
}
if (state.archivedAccounts.isNotEmpty()) {
item(key = "archived-header") {
SurferSectionHeader(
title = stringResource(Res.string.accounts_manage_archived_header),
trailing = {
SurferSectionHeaderHint(
text = stringResource(Res.string.accounts_manage_archived_hint),
)
},
modifier = Modifier
.padding(horizontal = AppTheme.spacing.large)
.padding(top = 20.dp, bottom = 6.dp),
)
}
items(state.archivedAccounts, key = { "archived-${it.id.value}" }) { account ->
SurferArchivedAccountCard(
name = account.name,
subtitle = archivedSubtitle(account),
icon = account.type.icon(),
restoreLabel = stringResource(Res.string.accounts_manage_restore),
onRestoreClick = { onEvent(AccountsManageEvent.OnRestoreAccountClick(account.id)) },
modifier = Modifier.padding(
horizontal = AppTheme.spacing.default,
vertical = AppTheme.spacing.xSmall,
),
)
}
}
item(key = "footnote") {
Text(
text = stringResource(Res.string.accounts_manage_archived_footnote),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = AppTheme.spacing.large)
.padding(top = 18.dp, bottom = AppTheme.spacing.small),
)
}
}
}
state.pendingDelete?.let { pending ->
DeleteAccountDialog(
accountName = pending.name,
onConfirm = { onEvent(AccountsManageEvent.OnDeleteConfirm) },
onDismiss = { onEvent(AccountsManageEvent.OnDeleteCancel) },
)
}
state.pendingArchive?.let { pending ->
ArchiveAccountDialog(
accountName = pending.name,
onConfirm = { onEvent(AccountsManageEvent.OnArchiveConfirm) },
onDismiss = { onEvent(AccountsManageEvent.OnArchiveCancel) },
)
}
}
/**
* Row key of an active account. Shared by the `LazyColumn` item and the reorder state — the
* drag reports moves as keys, and the two have to be the same string for a drop to land.
*/
private fun activeRowKey(id: AccountId): String = "active-${id.value}"
@Composable
private fun ActiveAccountRow(
account: AccountManageUi,
editing: Boolean,
archiveLabel: String,
deleteLabel: String,
onClick: () -> Unit,
onArchiveClick: () -> Unit,
onRemoveClick: () -> Unit,
handleModifier: Modifier,
modifier: Modifier = Modifier,
) {
val typeLabel = stringResource(account.type.labelRes())
SurferSwipeRevealRow(
revealWidth = 156.dp,
modifier = modifier.fillMaxWidth(),
enabled = !editing,
actions = {
SurferSwipeAction(
icon = SurferIcons.Archive,
label = archiveLabel,
onClick = onArchiveClick,
)
SurferSwipeAction(
icon = SurferIcons.Delete,
label = deleteLabel,
onClick = onRemoveClick,
destructive = true,
)
},
) {
SurferAccountManageCard(
name = account.name,
subtitle = typeLabel,
icon = account.type.icon(),
formattedBalance = account.formattedBalance,
onClick = if (editing) null else onClick,
trailing = if (editing) {
{ SurferDragHandle(modifier = handleModifier) }
} else {
null
},
)
}
}
@Composable
private fun archivedSubtitle(account: AccountManageUi): String {
val typeLabel = stringResource(account.type.labelRes())
// Accounts archived before `archivedAt` existed carry no date; the type label alone is the
// honest fallback.
val archivedOn = account.archivedOn ?: return typeLabel
val months = stringArrayResource(Res.array.month_short)
val archivedLabel = stringResource(
Res.string.accounts_manage_archived_at,
months[archivedOn.month.number - 1],
archivedOn.year.toString(),
)
return "$typeLabel · $archivedLabel"
}
@Preview
@Composable
private fun AccountsManageScreenViewPreview() {
AppTheme {
AccountsManageContent(
state = AccountsManageState.Content(
isEditing = false,
activeAccounts = PreviewActiveAccountsFull,
archivedAccounts = PreviewArchivedAccounts,
formattedTotal = "€11,575.32",
),
onEvent = {},
)
}
}
@Preview
@Composable
private fun AccountsManageScreenEditingPreview() {
AppTheme {
AccountsManageContent(
state = AccountsManageState.Content(
isEditing = true,
activeAccounts = PreviewActiveAccountsEditing,
archivedAccounts = emptyList(),
formattedTotal = "€11,395.32",
),
onEvent = {},
)
}
}
@Preview
@Composable
private fun AccountsManageEmptyPreview() {
AppTheme {
AccountsManageContent(
state = AccountsManageState.Content(
isEditing = false,
activeAccounts = emptyList(),
archivedAccounts = emptyList(),
formattedTotal = null,
),
onEvent = {},
)
}
}