Coverage Summary for Class: CategoriesManageScreenKt (com.georgeci.moneysurfer.feature.category.manage)

Class Method, % Branch, % Line, % Instruction, %
CategoriesManageScreenKt 66.7% (14/21) 26.7% (27/101) 76.4% (97/127) 51% (667/1309)
CategoriesManageScreenKt$CategoriesManageContent$3$2$1$2$1$1 0% (0/1) 0% (0/1) 0% (0/11)
CategoriesManageScreenKt$CategoriesManageContent$3$2$1$2$2$1 0% (0/1) 0% (0/1) 0% (0/11)
CategoriesManageScreenKt$CategoriesManageContent$3$2$1$2$3$1 0% (0/1) 0% (0/1) 0% (0/11)
CategoriesManageScreenKt$CategoriesManageContent$lambda$2$1$0$$inlined$items$default$1 0% (0/1)
CategoriesManageScreenKt$CategoriesManageContent$lambda$2$1$0$$inlined$items$default$2 0% (0/1)
CategoriesManageScreenKt$CategoriesManageContent$lambda$2$1$0$$inlined$items$default$3 0% (0/1)
CategoriesManageScreenKt$CategoriesManageContent$lambda$2$1$0$$inlined$items$default$4 0% (0/1)
CategoriesManageScreenKt$CategoriesManageScreen$1$1 0% (0/1) 0% (0/1)
CategoriesManageScreenKt$WhenMappings
Total 48.3% (14/29) 26.7% (27/101) 74% (97/131) 49.7% (667/1342)


 package com.georgeci.moneysurfer.feature.category.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.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.material3.Scaffold
 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.model.CategoryTree
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.CategoryType
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.components.SurferEmptyState
 import com.georgeci.moneysurfer.uikit.components.SurferSkeletonRow
 import com.georgeci.moneysurfer.uikit.components.base.SurferAddFab
 import com.georgeci.moneysurfer.uikit.components.base.SurferSwipeAction
 import com.georgeci.moneysurfer.uikit.components.base.SurferSwipeRevealRow
 import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
 import com.georgeci.moneysurfer.uikit.components.category.SurferCategoryManageCard
 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 moneysurfer.feature.category.generated.resources.Res
 import moneysurfer.feature.category.generated.resources.categories_manage_add
 import moneysurfer.feature.category.generated.resources.categories_manage_edit
 import moneysurfer.feature.category.generated.resources.categories_manage_empty
 import moneysurfer.feature.category.generated.resources.categories_manage_remove
 import moneysurfer.feature.category.generated.resources.categories_manage_title
 import moneysurfer.feature.category.generated.resources.category_creation_type_expense
 import moneysurfer.feature.category.generated.resources.category_creation_type_income
 import moneysurfer.feature.category.generated.resources.category_creation_type_transfer
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /** Extra leading inset per nesting level. One level is all [CategoryTree.MAX_DEPTH] allows. */
 private val ChildIndent = 24.dp
 
 /** Stable selectors for the Categories management screen — see docs/testing/testing-strategy.md. */
 object CategoriesManageTestTags {
     const val Root = "categoriesManage:root"
     const val AddButton = "categoriesManage:add"
 }
 
 @Composable
 fun CategoriesManageScreen(
     onNavigateBack: () -> Unit,
     onNavigateToCategoryCreation: () -> Unit,
     onNavigateToCategoryEdit: (CategoryId) -> Unit,
     onNavigateToCategoryDetails: (CategoryId) -> Unit = {},
     viewModel: CategoriesManageViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             CategoriesManageEffect.NavigateBack -> onNavigateBack()
             CategoriesManageEffect.NavigateToCategoryCreation -> onNavigateToCategoryCreation()
             is CategoriesManageEffect.NavigateToCategoryDetails ->
                 onNavigateToCategoryDetails(effect.categoryId)
             is CategoriesManageEffect.NavigateToCategoryEdit -> onNavigateToCategoryEdit(effect.categoryId)
         }
     }
 
     when (val current = state) {
         CategoriesManageState.Loading -> CategoriesManageLoading(onEvent = viewModel::onEvent)
         is CategoriesManageState.Content -> CategoriesManageContent(
             state = current,
             onEvent = viewModel::onEvent,
         )
     }
 }
 
 @Composable
 private fun CategoriesManageLoading(onEvent: (CategoriesManageEvent) -> Unit) {
     Scaffold(
         modifier = Modifier.surferSafeInsets(),
         containerColor = AppTheme.materialColors.surface,
         topBar = {
             SurferToolbar(
                 title = stringResource(Res.string.categories_manage_title),
                 onBack = { onEvent(CategoriesManageEvent.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 tree, 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 CategoriesManageContent(
     state: CategoriesManageState.Content,
     onEvent: (CategoriesManageEvent) -> Unit,
 ) {
     Scaffold(
         modifier = Modifier
             .surferSafeInsets()
             .testTag(CategoriesManageTestTags.Root)
             .surferTestTagAsId(),
         containerColor = AppTheme.materialColors.surface,
         topBar = {
             SurferToolbar(
                 title = stringResource(Res.string.categories_manage_title),
                 onBack = { onEvent(CategoriesManageEvent.OnBackClick) },
             )
         },
         floatingActionButton = {
             SurferAddFab(
                 label = stringResource(Res.string.categories_manage_add),
                 onClick = { onEvent(CategoriesManageEvent.OnAddCategoryClick) },
                 modifier = Modifier.testTag(CategoriesManageTestTags.AddButton),
             )
         },
     ) { padding ->
         if (state.categories.isEmpty()) {
             Box(
                 modifier = Modifier.fillMaxSize().surferContentContainer().padding(padding),
                 contentAlignment = Alignment.Center,
             ) {
                 SurferEmptyState(
                     title = stringResource(Res.string.categories_manage_empty),
                     icon = SurferIcons.Category,
                 )
             }
             return@Scaffold
         }
 
         LazyColumn(
             modifier = Modifier
                 .fillMaxSize()
                 .surferContentContainer()
                 .padding(top = padding.calculateTopPadding()),
             contentPadding = PaddingValues(
                 top = AppTheme.spacing.small,
                 bottom = padding.calculateBottomPadding() +
                     AppTheme.spacing.xxxLarge + AppTheme.spacing.xLarge,
             ),
         ) {
             items(state.categories, key = { it.id.value }) { category ->
                 CategoryManageRow(
                     category = category,
                     deleteLabel = stringResource(Res.string.categories_manage_remove),
                     editLabel = stringResource(Res.string.categories_manage_edit),
                     onClick = { onEvent(CategoriesManageEvent.OnCategoryClick(category.id)) },
                     onEditClick = { onEvent(CategoriesManageEvent.OnEditCategoryClick(category.id)) },
                     onRemoveClick = { onEvent(CategoriesManageEvent.OnRemoveCategoryClick(category.id)) },
                     modifier = Modifier
                         .fillMaxWidth()
                         .padding(
                             // Children are indented under the parent they follow — the list is
                             // already in tree order, so the indent is all the nesting needs.
                             start = AppTheme.spacing.default + ChildIndent * category.depth,
                             end = AppTheme.spacing.default,
                         )
                         .padding(vertical = AppTheme.spacing.xSmall),
                 )
             }
         }
     }
 
     state.pendingDelete?.let { pending ->
         DeleteCategoryDialog(
             categoryName = pending.name,
             childCount = pending.childCount,
             onConfirm = { onEvent(CategoriesManageEvent.OnDeleteConfirm) },
             onDismiss = { onEvent(CategoriesManageEvent.OnDeleteCancel) },
         )
     }
 }
 
 @Composable
 private fun CategoryManageRow(
     category: CategoryManageUi,
     deleteLabel: String,
     editLabel: String,
     onClick: () -> Unit,
     onEditClick: () -> Unit,
     onRemoveClick: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
     SurferSwipeRevealRow(
         // Two actions now that tapping the row opens the detail screen instead of the editor —
         // edit has to stay reachable from the list itself. 156dp is what two 72dp actions plus
         // the gaps measure, matching AccountsManage.
         revealWidth = 156.dp,
         modifier = modifier,
         actions = {
             SurferSwipeAction(
                 icon = SurferIcons.Edit,
                 label = editLabel,
                 onClick = onEditClick,
             )
             SurferSwipeAction(
                 icon = SurferIcons.Delete,
                 label = deleteLabel,
                 onClick = onRemoveClick,
                 destructive = true,
             )
         },
     ) {
         CategoryManageCard(category = category, onClick = onClick)
     }
 }
 
 @Composable
 private fun CategoryManageCard(
     category: CategoryManageUi,
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
     val visual = SurferCategoryPalette.visualFor(
         id = category.id.value,
         iconKey = category.iconKey,
         hue = category.hue,
         systemKind = category.systemKind,
     )
     SurferCategoryManageCard(
         name = category.name,
         typeLabel = categoryTypeLabel(category.type),
         icon = visual.icon,
         tint = visual.tint,
         onClick = onClick,
         modifier = modifier,
     )
 }
 
 @Composable
 private fun categoryTypeLabel(type: CategoryType): String =
     when (type) {
         CategoryType.EXPENSE -> stringResource(Res.string.category_creation_type_expense)
         CategoryType.INCOME -> stringResource(Res.string.category_creation_type_income)
         CategoryType.TRANSFER -> stringResource(Res.string.category_creation_type_transfer)
     }
 
 @Preview
 @Composable
 private fun CategoriesManageContentPreview() {
     AppTheme {
         CategoriesManageContent(
             state = CategoriesManageState.Content(
                 categories = listOf(
                     CategoryManageUi(CategoryId("1"), "Food", CategoryType.EXPENSE),
                     CategoryManageUi(
                         id = CategoryId("2"),
                         name = "Groceries",
                         type = CategoryType.EXPENSE,
                         parentId = CategoryId("1"),
                         depth = 1,
                     ),
                     CategoryManageUi(CategoryId("3"), "Salary", CategoryType.INCOME),
                 ),
             ),
             onEvent = {},
         )
     }
 }