Coverage Summary for Class: CategoryChooserContentKt (com.georgeci.moneysurfer.feature.category.picker)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryChooserContentKt |
0%
(0/15)
|
0%
(0/58)
|
0%
(0/171)
|
0.7%
(12/1751)
|
| CategoryChooserContentKt$CategoryChooserContent$1$5$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| CategoryChooserContentKt$CategoryChooserContent$1$7$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| CategoryChooserContentKt$CategoryChooserContent$1$8$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| CategoryChooserContentKt$WhenMappings |
|
| Total |
0%
(0/18)
|
0%
(0/58)
|
0%
(0/174)
|
0.7%
(12/1778)
|
package com.georgeci.moneysurfer.feature.category.picker
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.uikit.components.SurferSearchField
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.preview.SurferBottomSheetPreview
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_creation_close_content_description
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 moneysurfer.feature.category.generated.resources.category_picker_currently
import moneysurfer.feature.category.generated.resources.category_picker_show_grid
import moneysurfer.feature.category.generated.resources.category_picker_show_tree
import moneysurfer.feature.category.generated.resources.transaction_creation_category_search
import moneysurfer.feature.category.generated.resources.transaction_creation_category_sheet_title
import org.jetbrains.compose.resources.stringResource
/** The title block sits one step wider than the body rows below it, as the artboard has it. */
private val SheetHeaderPadding = 20.dp
/** Shared with both variant bodies so the grid, the cards and the search field line up. */
internal val SheetBodyPadding = 16.dp
private val TabHeight = 36.dp
private val TabRadius = 18.dp
private val TabGlyphSize = 16.dp
private val TabSpacing = 8.dp
private val TabLabelSpacing = 6.dp
private val SublineSpacing = 2.dp
private val ChromeSpacing = 12.dp
private val SheetBottomPadding = 16.dp
/**
* Everything the category-chooser sheet draws below the drag handle: the title block with the
* `Currently: …` subline and a close action, the Expense / Income / Transfer tab row, the search
* field, and then whichever of the two designed bodies [CategoryChooserState.Content.variant]
* asks for.
*
* The sheet shell itself — 28dp top corners, `surfaceContainerLow`, the 32×4 handle — comes from
* the `ModalBottomSheet` that `BottomSheetSceneStrategy` wraps this entry in, so it is
* deliberately not redrawn here.
*/
@Composable
internal fun CategoryChooserContent(
state: CategoryChooserState.Content,
onEvent: (CategoryChooserEvent) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = SheetBottomPadding),
) {
ChooserHeader(
selectedName = state.selectedName,
variant = state.variant,
onToggleVariant = { onEvent(CategoryChooserEvent.OnVariantToggle) },
onClose = { onEvent(CategoryChooserEvent.OnDismiss) },
)
if (!state.typeLocked) {
Spacer(Modifier.height(ChromeSpacing))
TypeTabRow(
selected = state.type,
onSelect = { onEvent(CategoryChooserEvent.OnTypeSelected(it)) },
)
}
Spacer(Modifier.height(ChromeSpacing))
SurferSearchField(
value = state.query,
onValueChange = { onEvent(CategoryChooserEvent.OnQueryChange(it)) },
placeholder = stringResource(Res.string.transaction_creation_category_search),
containerColor = AppTheme.materialColors.surfaceContainerHighest,
)
Spacer(Modifier.height(ChromeSpacing))
when (state.variant) {
CategoryPickerVariant.GRID -> CategoryPickerGrid(
items = state.gridItems,
onSelect = { onEvent(CategoryChooserEvent.OnCategorySelected(it)) },
onCreateNew = { onEvent(CategoryChooserEvent.OnCreateNewCategoryClick) },
)
CategoryPickerVariant.TREE -> CategoryPickerTree(
groups = state.treeItems,
onSelect = { onEvent(CategoryChooserEvent.OnCategorySelected(it)) },
onToggleExpand = { onEvent(CategoryChooserEvent.OnParentExpandToggle(it)) },
onCreateNew = { onEvent(CategoryChooserEvent.OnCreateNewCategoryClick) },
)
}
}
}
@Composable
private fun ChooserHeader(
selectedName: String?,
variant: CategoryPickerVariant,
onToggleVariant: () -> Unit,
onClose: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = SheetHeaderPadding, end = SheetBodyPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(Res.string.transaction_creation_category_sheet_title),
style = AppTheme.typography.titleMedium,
color = AppTheme.materialColors.onSurface,
)
// Only meaningful once something is picked — an empty "Currently:" reads as a bug.
if (selectedName != null) {
Spacer(Modifier.height(SublineSpacing))
Text(
text = stringResource(Res.string.category_picker_currently, selectedName),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
}
// Shows the layout it switches *to*, so the glyph reads as the destination rather than
// as a label for what is already on screen.
IconButton(onClick = onToggleVariant) {
Icon(
imageVector = when (variant) {
CategoryPickerVariant.GRID -> SurferIcons.ListView
CategoryPickerVariant.TREE -> SurferIcons.GridView
},
contentDescription = stringResource(
when (variant) {
CategoryPickerVariant.GRID -> Res.string.category_picker_show_tree
CategoryPickerVariant.TREE -> Res.string.category_picker_show_grid
},
),
tint = AppTheme.materialColors.onSurfaceVariant,
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = SurferIcons.Close,
contentDescription = stringResource(
Res.string.category_creation_close_content_description,
),
tint = AppTheme.materialColors.onSurfaceVariant,
)
}
}
}
@Composable
private fun TypeTabRow(
selected: CategoryType,
onSelect: (CategoryType) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetBodyPadding),
horizontalArrangement = Arrangement.spacedBy(TabSpacing),
) {
CategoryType.entries.forEach { type ->
TypeTab(
label = categoryTypeLabel(type),
glyph = typeGlyph(type),
selected = type == selected,
onClick = { onSelect(type) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun TypeTab(
label: String,
glyph: ImageVector,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(TabRadius)
val content = if (selected) {
AppTheme.materialColors.onSecondaryContainer
} else {
AppTheme.materialColors.onSurfaceVariant
}
Row(
modifier = modifier
.height(TabHeight)
.clip(shape)
.background(
if (selected) AppTheme.materialColors.secondaryContainer else Color.Transparent,
)
.then(
if (selected) {
Modifier
} else {
Modifier.border(1.dp, AppTheme.materialColors.outlineVariant, shape)
},
)
.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
imageVector = glyph,
contentDescription = SurferSemantics.Decorative,
tint = content,
modifier = Modifier.size(TabGlyphSize),
)
Spacer(Modifier.width(TabLabelSpacing))
Text(text = label, style = AppTheme.typography.labelLarge, color = content)
}
}
private fun typeGlyph(type: CategoryType): ImageVector = when (type) {
CategoryType.EXPENSE -> SurferIcons.ArrowDown
CategoryType.INCOME -> SurferIcons.ArrowUp
CategoryType.TRANSFER -> SurferIcons.SwapHoriz
}
@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)
}
private fun previewItem(
id: String,
name: String,
iconKey: String,
hue: Int,
selected: Boolean = false,
) = CategoryPickerItem(
id = CategoryId(id),
name = name,
iconKey = iconKey,
hue = hue,
systemKind = null,
selected = selected,
)
private val previewGridItems = listOf(
previewItem("groceries", "Groceries", "cash", 162, selected = true),
previewItem("dining", "Dining", "receipt", 35),
previewItem("transport", "Transport", "card", 258),
previewItem("rent", "Rent", "wallet", 303),
previewItem("fuel", "Fuel", "tag", 68),
previewItem("pharmacy", "Pharmacy", "event", 340),
)
private val previewTreeGroups = listOf(
CategoryTreeGroup(
parent = previewItem("food", "Food", "cash", 162),
childCount = 2,
expanded = true,
children = listOf(
previewItem("groceries", "Groceries", "cash", 162, selected = true),
previewItem("dining", "Dining", "receipt", 35),
),
),
CategoryTreeGroup(
parent = previewItem("transport", "Transport", "card", 258),
childCount = 3,
),
CategoryTreeGroup(parent = previewItem("rent", "Rent", "wallet", 303)),
)
private fun previewState(variant: CategoryPickerVariant) = CategoryChooserState.Content(
selectedId = CategoryId("groceries"),
selectedName = "Groceries",
type = CategoryType.EXPENSE,
typeLocked = false,
query = "",
expandedIds = setOf(CategoryId("food")),
variant = variant,
gridItems = previewGridItems,
treeItems = previewTreeGroups,
)
@Preview
@Composable
private fun CategoryChooserGridPreview() {
SurferBottomSheetPreview {
CategoryChooserContent(state = previewState(CategoryPickerVariant.GRID), onEvent = {})
}
}
@Preview
@Composable
private fun CategoryChooserTreePreview() {
SurferBottomSheetPreview {
CategoryChooserContent(state = previewState(CategoryPickerVariant.TREE), onEvent = {})
}
}
@Preview
@Composable
private fun CategoryChooserGridDarkPreview() {
SurferBottomSheetPreview(darkTheme = true) {
CategoryChooserContent(state = previewState(CategoryPickerVariant.GRID), onEvent = {})
}
}
@Preview
@Composable
private fun CategoryChooserTreeDarkPreview() {
SurferBottomSheetPreview(darkTheme = true) {
CategoryChooserContent(state = previewState(CategoryPickerVariant.TREE), onEvent = {})
}
}