Coverage Summary for Class: CategoryPickerTreeKt (com.georgeci.moneysurfer.feature.category.picker)

Class Method, % Branch, % Line, % Instruction, %
CategoryPickerTreeKt 0% (0/15) 0% (0/88) 0% (0/221) 0% (0/2035)
CategoryPickerTreeKt$CategoryPickerTree$lambda$0$0$$inlined$items$default$1 0% (0/1)
CategoryPickerTreeKt$CategoryPickerTree$lambda$0$0$$inlined$items$default$2 0% (0/1)
CategoryPickerTreeKt$CategoryPickerTree$lambda$0$0$$inlined$items$default$3 0% (0/1)
CategoryPickerTreeKt$CategoryPickerTree$lambda$0$0$$inlined$items$default$4 0% (0/1)
Total 0% (0/19) 0% (0/88) 0% (0/221) 0% (0/2035)


 package com.georgeci.moneysurfer.feature.category.picker
 
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
 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.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
 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.selection.selectable
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 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.draw.clip
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 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.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.modifier.surferDashedBorder
 import com.georgeci.moneysurfer.uikit.semantics.SurferSemantics
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import moneysurfer.feature.category.generated.resources.Res
 import moneysurfer.feature.category.generated.resources.category_picker_hide_children
 import moneysurfer.feature.category.generated.resources.category_picker_new_category
 import moneysurfer.feature.category.generated.resources.category_picker_show_children
 import moneysurfer.feature.category.generated.resources.category_picker_subcategories
 import org.jetbrains.compose.resources.pluralStringResource
 import org.jetbrains.compose.resources.stringResource
 
 private val TreeMinHeight = 200.dp
 private val TreeMaxHeight = 420.dp
 private val CardRadius = 18.dp
 private val CardSpacing = 8.dp
 private val ParentBubbleSize = 40.dp
 private val ChildBubbleSize = 28.dp
 private val RowVerticalPadding = 12.dp
 private val RowHorizontalPadding = 12.dp
 private val BubbleSpacing = 12.dp
 
 /** Where the L-connector's rules sit, measured from the card's leading edge. */
 private val ConnectorX = 33.dp
 private val ConnectorArm = 11.dp
 private val ConnectorStroke = 1.5.dp
 private val ChildContentStart = 48.dp
 
 private val RadioSize = 22.dp
 private val RadioStroke = 2.dp
 private val RadioCheckSize = 14.dp
 private val HairlineHeight = 1.dp
 private const val ChevronExpandedDegrees = 180f
 private const val ChevronRotationMillis = 140
 
 /**
  * Variant B of the picker: one card per top-level category, with its children folded away behind
  * a chevron until asked for. Built for someone browsing rather than searching — the grid answers
  * "where is Groceries", this answers "what is under Food".
  *
  * A parent is selectable in its own right even when it has children: the row selects, the chevron
  * expands, and they are separate hit targets so neither steals the other's tap.
  */
 @Composable
 internal fun CategoryPickerTree(
     groups: List<CategoryTreeGroup>,
     onSelect: (CategoryId) -> Unit,
     onToggleExpand: (CategoryId) -> Unit,
     onCreateNew: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
     LazyColumn(
         modifier = modifier
             .fillMaxWidth()
             .heightIn(min = TreeMinHeight, max = TreeMaxHeight),
         contentPadding = PaddingValues(horizontal = SheetBodyPadding),
         verticalArrangement = Arrangement.spacedBy(CardSpacing),
     ) {
         items(groups, key = { it.parent.id.value }) { group ->
             CategoryTreeCard(
                 group = group,
                 onSelect = onSelect,
                 onToggleExpand = onToggleExpand,
             )
         }
         item(key = "create-new") {
             NewCategoryRow(onClick = onCreateNew)
         }
     }
 }
 
 @Composable
 private fun CategoryTreeCard(
     group: CategoryTreeGroup,
     onSelect: (CategoryId) -> Unit,
     onToggleExpand: (CategoryId) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     Column(
         modifier = modifier
             .fillMaxWidth()
             .clip(RoundedCornerShape(CardRadius))
             .background(AppTheme.materialColors.surfaceContainerLowest)
             .border(
                 width = HairlineHeight,
                 color = AppTheme.materialColors.outlineVariant,
                 shape = RoundedCornerShape(CardRadius),
             ),
     ) {
         ParentRow(
             group = group,
             onSelect = { onSelect(group.parent.id) },
             onToggleExpand = { onToggleExpand(group.parent.id) },
         )
         if (group.children.isNotEmpty()) {
             Box(
                 modifier = Modifier
                     .fillMaxWidth()
                     .height(HairlineHeight)
                     .background(AppTheme.materialColors.outlineVariant),
             )
             Column(modifier = Modifier.background(AppTheme.materialColors.surfaceContainerLow)) {
                 group.children.forEachIndexed { index, child ->
                     ChildRow(
                         child = child,
                         isLast = index == group.children.lastIndex,
                         onSelect = { onSelect(child.id) },
                     )
                 }
             }
         }
     }
 }
 
 @Composable
 private fun ParentRow(
     group: CategoryTreeGroup,
     onSelect: () -> Unit,
     onToggleExpand: () -> Unit,
 ) {
     val parent = group.parent
     val visual = SurferCategoryPalette.visualFor(
         id = parent.id.value,
         iconKey = parent.iconKey,
         hue = parent.hue,
         systemKind = parent.systemKind,
     )
     Row(
         modifier = Modifier
             .fillMaxWidth()
             .background(selectionBackground(parent.selected))
             .selectable(
                 selected = parent.selected,
                 role = Role.RadioButton,
                 onClick = onSelect,
             )
             .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         SurferCategoryBubble(
             icon = visual.icon,
             tint = visual.tint,
             size = ParentBubbleSize,
             modifier = Modifier.padding(end = BubbleSpacing),
         )
         Column(modifier = Modifier.weight(1f)) {
             Text(
                 text = parent.name,
                 style = AppTheme.typography.titleSmall,
                 color = AppTheme.materialColors.onSurface,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis,
             )
             if (group.childCount > 0) {
                 Text(
                     text = pluralStringResource(
                         Res.plurals.category_picker_subcategories,
                         group.childCount,
                         group.childCount,
                     ),
                     style = AppTheme.typography.bodySmall,
                     color = AppTheme.materialColors.onSurfaceVariant,
                 )
             }
         }
         SelectionRadio(selected = parent.selected)
         if (group.childCount > 0) {
             val rotation by animateFloatAsState(
                 targetValue = if (group.expanded) ChevronExpandedDegrees else 0f,
                 animationSpec = tween(ChevronRotationMillis),
                 label = "chevron",
             )
             IconButton(onClick = onToggleExpand) {
                 Icon(
                     imageVector = SurferIcons.DropDown,
                     contentDescription = stringResource(
                         if (group.expanded) {
                             Res.string.category_picker_hide_children
                         } else {
                             Res.string.category_picker_show_children
                         },
                     ),
                     tint = AppTheme.materialColors.onSurfaceVariant,
                     modifier = Modifier.rotate(rotation),
                 )
             }
         }
     }
 }
 
 @Composable
 private fun ChildRow(
     child: CategoryPickerItem,
     isLast: Boolean,
     onSelect: () -> Unit,
 ) {
     val visual = SurferCategoryPalette.visualFor(
         id = child.id.value,
         iconKey = child.iconKey,
         hue = child.hue,
         systemKind = child.systemKind,
     )
     val connector = AppTheme.materialColors.outlineVariant
     Row(
         modifier = Modifier
             .fillMaxWidth()
             .background(selectionBackground(child.selected))
             .selectable(
                 selected = child.selected,
                 role = Role.RadioButton,
                 onClick = onSelect,
             )
             .drawBehind {
                 val stroke = ConnectorStroke.toPx()
                 val x = ConnectorX.toPx()
                 val midY = size.height / 2f
                 // The last child ends the run, so its vertical rule stops at the elbow instead
                 // of carrying on into empty space.
                 drawLine(
                     color = connector,
                     start = Offset(x, 0f),
                     end = Offset(x, if (isLast) midY else size.height),
                     strokeWidth = stroke,
                 )
                 drawLine(
                     color = connector,
                     start = Offset(x, midY),
                     end = Offset(x + ConnectorArm.toPx(), midY),
                     strokeWidth = stroke,
                 )
             }
             .padding(
                 start = ChildContentStart,
                 end = RowHorizontalPadding,
                 top = RowVerticalPadding,
                 bottom = RowVerticalPadding,
             ),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         SurferCategoryBubble(
             icon = visual.icon,
             tint = visual.tint,
             size = ChildBubbleSize,
             modifier = Modifier.padding(end = BubbleSpacing),
         )
         Text(
             text = child.name,
             style = AppTheme.typography.bodyLarge,
             color = AppTheme.materialColors.onSurface,
             maxLines = 1,
             overflow = TextOverflow.Ellipsis,
             modifier = Modifier.weight(1f),
         )
         SelectionRadio(selected = child.selected)
     }
 }
 
 @Composable
 private fun SelectionRadio(selected: Boolean) {
     val primary = AppTheme.materialColors.primary
     val outline = AppTheme.materialColors.outline
     Box(
         modifier = Modifier
             .size(RadioSize)
             .clip(CircleShape)
             .background(if (selected) primary else Color.Transparent)
             .drawBehind {
                 if (selected) return@drawBehind
                 val stroke = RadioStroke.toPx()
                 drawCircle(
                     color = outline,
                     radius = (size.minDimension - stroke) / 2f,
                     style = Stroke(width = stroke),
                 )
             },
         contentAlignment = Alignment.Center,
     ) {
         if (selected) {
             Icon(
                 imageVector = SurferIcons.Check,
                 contentDescription = SurferSemantics.Decorative,
                 tint = AppTheme.materialColors.onPrimary,
                 modifier = Modifier.size(RadioCheckSize),
             )
         }
     }
 }
 
 @Composable
 private fun NewCategoryRow(
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
     val outline = AppTheme.materialColors.outline
     val shape = RoundedCornerShape(CardRadius)
     Row(
         modifier = modifier
             .fillMaxWidth()
             .padding(top = CardSpacing)
             .clip(shape)
             .clickable(onClick = onClick)
             .surferDashedBorder(color = outline, shape = shape)
             .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         Icon(
             imageVector = SurferIcons.Add,
             contentDescription = SurferSemantics.Decorative,
             tint = AppTheme.materialColors.primary,
             modifier = Modifier
                 .padding(end = BubbleSpacing)
                 .size(ParentBubbleSize / 2),
         )
         Text(
             text = stringResource(Res.string.category_picker_new_category),
             style = AppTheme.typography.labelLarge,
             color = AppTheme.materialColors.primary,
         )
     }
 }
 
 @Composable
 private fun selectionBackground(selected: Boolean): Color =
     if (selected) AppTheme.materialColors.secondaryContainer else Color.Transparent