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 = {},
         )
     }
 }