Coverage Summary for Class: CategoryCreationScreenKt (com.georgeci.moneysurfer.feature.category.creation)

Class Method, % Branch, % Line, % Instruction, %
CategoryCreationScreenKt 78.3% (18/23) 42.9% (42/98) 87.9% (160/182) 80.1% (1767/2207)
CategoryCreationScreenKt$CategoryCreationContent$2$1$7$1 0% (0/1) 0% (0/1) 0% (0/9)
CategoryCreationScreenKt$WhenMappings
Total 75% (18/24) 42.9% (42/98) 87.4% (160/183) 79.7% (1767/2216)


 package com.georgeci.moneysurfer.feature.category.creation
 
 import androidx.compose.foundation.layout.Arrangement
 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.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 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.testTag
 import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryBubble
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.components.SurferDropdown
 import com.georgeci.moneysurfer.uikit.components.base.SurferSectionLabel
 import com.georgeci.moneysurfer.uikit.components.base.SurferSegmentedControl
 import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
 import com.georgeci.moneysurfer.uikit.components.base.SurferToolbarButtonAction
 import com.georgeci.moneysurfer.uikit.components.category.SurferColorSwatchRow
 import com.georgeci.moneysurfer.uikit.components.category.SurferIconPickerGrid
 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.AmountInputTransformation
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import moneysurfer.feature.category.generated.resources.Res
 import moneysurfer.feature.category.generated.resources.category_creation_cap_hint
 import moneysurfer.feature.category.generated.resources.category_creation_cap_label
 import moneysurfer.feature.category.generated.resources.category_creation_cap_managed
 import moneysurfer.feature.category.generated.resources.category_creation_cap_managed_hint
 import moneysurfer.feature.category.generated.resources.category_creation_color_label
 import moneysurfer.feature.category.generated.resources.category_creation_field_parent
 import moneysurfer.feature.category.generated.resources.category_creation_icon_label
 import moneysurfer.feature.category.generated.resources.category_creation_name_counter
 import moneysurfer.feature.category.generated.resources.category_creation_name_error_required
 import moneysurfer.feature.category.generated.resources.category_creation_name_label
 import moneysurfer.feature.category.generated.resources.category_creation_parent_none
 import moneysurfer.feature.category.generated.resources.category_creation_save
 import moneysurfer.feature.category.generated.resources.category_creation_title_create
 import moneysurfer.feature.category.generated.resources.category_creation_title_edit
 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_label
 import moneysurfer.feature.category.generated.resources.category_creation_untitled
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 import org.koin.core.parameter.parametersOf
 
 private val SectionSpacing = 24.dp
 
 /** Stable selectors for the category create/edit screen — see docs/testing/testing-strategy.md. */
 object CategoryCreationTestTags {
     const val Root = "categoryCreation:root"
     const val NameField = "categoryCreation:name"
     const val SaveButton = "categoryCreation:save"
 }
 
 @Composable
 fun CategoryCreationScreen(
     categoryId: CategoryId? = null,
     onNavigateBack: () -> Unit,
     viewModel: CategoryCreationViewModel = koinViewModel(
         key = categoryId?.value ?: "new",
     ) { parametersOf(categoryId) },
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             CategoryCreationEffect.NavigateBack -> onNavigateBack()
         }
     }
 
     CategoryCreationContent(
         state = state,
         isEditing = categoryId != null,
         onEvent = viewModel::onEvent,
     )
 }
 
 /**
  * The editor, 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 CategoryCreationContent(
     state: CategoryCreationState,
     isEditing: Boolean,
     onEvent: (CategoryCreationEvent) -> Unit,
 ) {
     // Same resolver every other category bubble in the app goes through, so the preview card
     // shows exactly what the manage list and transaction screens will show once saved.
     val visual = SurferCategoryPalette.visualFor(
         id = "",
         iconKey = state.iconKey,
         hue = state.hue,
     )
 
     Scaffold(
         modifier = Modifier
             .surferSafeInsets()
             .testTag(CategoryCreationTestTags.Root)
             .surferTestTagAsId(),
         containerColor = AppTheme.materialColors.surface,
         topBar = {
             SurferToolbar(
                 title = stringResource(
                     if (isEditing) {
                         Res.string.category_creation_title_edit
                     } else {
                         Res.string.category_creation_title_create
                     },
                 ),
                 onBack = { onEvent(CategoryCreationEvent.OnBackClick) },
                 actions = {
                     SurferToolbarButtonAction(
                         icon = SurferIcons.Check,
                         text = stringResource(Res.string.category_creation_save),
                         onClick = { onEvent(CategoryCreationEvent.OnSaveClick) },
                         enabled = state.canSave,
                         modifier = Modifier.testTag(CategoryCreationTestTags.SaveButton),
                     )
                 },
             )
         },
     ) { padding ->
         Column(
             modifier = Modifier
                 .fillMaxSize()
                 .surferContentContainer()
                 .padding(top = padding.calculateTopPadding())
                 .verticalScroll(rememberScrollState())
                 .padding(horizontal = SectionSpacing)
                 .padding(top = AppTheme.spacing.small),
             verticalArrangement = Arrangement.spacedBy(SectionSpacing),
         ) {
             PreviewCard(
                 name = state.name.ifBlank { stringResource(Res.string.category_creation_untitled) },
                 typeLabel = stringResource(
                     if (state.type == CategoryTypeUi.Income) {
                         Res.string.category_creation_type_income
                     } else {
                         Res.string.category_creation_type_expense
                     },
                 ),
                 icon = visual.icon,
                 tint = visual.tint,
             )
 
             TypeSelector(
                 selected = state.type,
                 onChange = { onEvent(CategoryCreationEvent.OnTypeChanged(it)) },
             )
 
             OutlinedTextField(
                 value = state.name,
                 onValueChange = { onEvent(CategoryCreationEvent.OnNameChanged(it)) },
                 label = { Text(stringResource(Res.string.category_creation_name_label)) },
                 singleLine = true,
                 isError = state.nameMissing,
                 supportingText = when {
                     state.nameMissing -> {
                         { Text(stringResource(Res.string.category_creation_name_error_required)) }
                     }
                     state.showNameCounter -> {
                         {
                             Text(
                                 stringResource(
                                     Res.string.category_creation_name_counter,
                                     state.name.length,
                                     CategoryCreationState.NAME_MAX_LENGTH,
                                 ),
                             )
                         }
                     }
                     else -> null
                 },
                 modifier = Modifier
                     .fillMaxWidth()
                     .testTag(CategoryCreationTestTags.NameField),
             )
 
             Column {
                 SurferSectionLabel(stringResource(Res.string.category_creation_icon_label))
                 Spacer(Modifier.height(10.dp))
                 SurferIconPickerGrid(
                     selectedKey = state.iconKey,
                     tint = visual.tint,
                     onSelect = { onEvent(CategoryCreationEvent.OnIconSelected(it)) },
                 )
             }
 
             Column {
                 SurferSectionLabel(stringResource(Res.string.category_creation_color_label))
                 Spacer(Modifier.height(10.dp))
                 SurferColorSwatchRow(
                     selectedHue = state.hue,
                     onSelect = { onEvent(CategoryCreationEvent.OnColorSelected(it)) },
                 )
             }
 
             ParentPicker(
                 selectedId = state.parentId,
                 options = state.parentOptions,
                 onSelect = { onEvent(CategoryCreationEvent.OnParentSelected(it)) },
             )
 
             if (state.showCap) {
                 CapField(
                     cap = state.cap,
                     managedByBudgetName = state.capManagedByBudgetName,
                     onChange = { onEvent(CategoryCreationEvent.OnCapChanged(it)) },
                 )
             }
 
             Spacer(Modifier.height(padding.calculateBottomPadding() + SectionSpacing))
         }
     }
 }
 
 @Composable
 private fun PreviewCard(
     name: String,
     typeLabel: String,
     icon: ImageVector,
     tint: Color,
 ) {
     Card(
         modifier = Modifier.fillMaxWidth(),
         colors = CardDefaults.cardColors(
             containerColor = AppTheme.materialColors.surfaceContainerHigh,
         ),
         shape = RoundedCornerShape(20.dp),
     ) {
         Row(
             modifier = Modifier.padding(AppTheme.spacing.default),
             verticalAlignment = Alignment.CenterVertically,
             horizontalArrangement = Arrangement.spacedBy(14.dp),
         ) {
             SurferCategoryBubble(icon = icon, tint = tint, size = 48.dp)
             Column(modifier = Modifier.weight(1f)) {
                 Text(
                     text = name,
                     style = AppTheme.typography.titleMedium,
                     color = AppTheme.materialColors.onSurface,
                     maxLines = 1,
                     overflow = TextOverflow.Ellipsis,
                 )
                 Text(
                     text = typeLabel,
                     style = AppTheme.typography.bodySmall,
                     color = AppTheme.materialColors.onSurfaceVariant,
                 )
             }
         }
     }
 }
 
 @Composable
 private fun TypeSelector(
     selected: CategoryTypeUi,
     onChange: (CategoryTypeUi) -> Unit,
 ) {
     Column {
         SurferSectionLabel(stringResource(Res.string.category_creation_type_label))
         Spacer(Modifier.height(AppTheme.spacing.small))
         SurferSegmentedControl(
             options = listOf(CategoryTypeUi.Expense, CategoryTypeUi.Income),
             selected = selected,
             label = { type ->
                 stringResource(
                     when (type) {
                         CategoryTypeUi.Expense -> Res.string.category_creation_type_expense
                         CategoryTypeUi.Income -> Res.string.category_creation_type_income
                     },
                 )
             },
             onSelect = onChange,
         )
     }
 }
 
 /**
  * Parent selection over the eligible options the ViewModel computed — the category itself, its
  * descendants, system rows and the wrong type are already gone from [options], so anything
  * offered here is safe to save.
  *
  * "None" is an entry in the list rather than a separate control: having no parent is an ordinary
  * choice for a top-level category, not the absence of one.
  */
 @Composable
 private fun ParentPicker(
     selectedId: CategoryId?,
     options: List<CategoryParentOption>,
     onSelect: (CategoryId?) -> Unit,
 ) {
     val noneLabel = stringResource(Res.string.category_creation_parent_none)
     val entries = listOf<CategoryParentOption?>(null) + options
     SurferDropdown(
         items = entries,
         selected = entries.firstOrNull { it?.id == selectedId },
         label = stringResource(Res.string.category_creation_field_parent),
         itemName = { it?.name ?: noneLabel },
         onItemSelected = { onSelect(it?.id) },
     )
 }
 
 /**
  * The monthly-cap shortcut. `Category` has no `cap` field — this is a two-tap front end for a
  * budget whose only category is this one (md/categories.md, decision 2). Clearing it deletes that
  * budget; leaving it empty never creates one.
  *
  * When a budget the user built on the Budgets screen already limits this category *alongside
  * others*, the field is replaced by a read-only notice naming that budget. Offering an editable
  * cap there would put two limits on one category with no defined winner, which is exactly what
  * keeping limits in Budgets was meant to avoid. The all-categories budget is not treated as such
  * coverage: it is the global envelope that per-category budgets already live inside.
  */
 @Composable
 private fun CapField(
     cap: String,
     managedByBudgetName: String?,
     onChange: (String) -> Unit,
 ) {
     if (managedByBudgetName != null) {
         Column {
             SurferSectionLabel(stringResource(Res.string.category_creation_cap_label))
             Spacer(Modifier.height(10.dp))
             Text(
                 text = stringResource(Res.string.category_creation_cap_managed, managedByBudgetName),
                 style = AppTheme.typography.bodyLarge,
                 color = AppTheme.materialColors.onSurface,
             )
             Spacer(Modifier.height(4.dp))
             Text(
                 text = stringResource(Res.string.category_creation_cap_managed_hint),
                 style = AppTheme.typography.bodySmall,
                 color = AppTheme.materialColors.onSurfaceVariant,
             )
         }
     } else {
         OutlinedTextField(
             value = cap,
             onValueChange = { input -> AmountInputTransformation.validateAndNormalize(input)?.let(onChange) },
             label = { Text(stringResource(Res.string.category_creation_cap_label)) },
             supportingText = { Text(stringResource(Res.string.category_creation_cap_hint)) },
             keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
             singleLine = true,
             modifier = Modifier.fillMaxWidth(),
         )
     }
 }
 
 @Preview
 @Composable
 private fun CategoryCreationPreview() {
     AppTheme {
         CategoryCreationContent(
             state = CategoryCreationState(
                 name = "Coffee",
                 iconKey = SurferCategoryPalette.iconKeys[1],
                 hue = SurferCategoryPalette.hues[1],
                 parentOptions = listOf(CategoryParentOption(CategoryId("c-1"), "Dining")),
                 parentId = CategoryId("c-1"),
                 cap = "250",
             ),
             isEditing = false,
             onEvent = {},
         )
     }
 }