Coverage Summary for Class: SurferCategoryAppearancePickerKt (com.georgeci.moneysurfer.uikit.components.category)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SurferCategoryAppearancePickerKt |
100%
(1/1)
|
87.5%
(7/8)
|
57.4%
(39/68)
|
99.1%
(108/109)
|
96.4%
(1296/1344)
|
package com.georgeci.moneysurfer.uikit.components.category
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.size
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.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.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.preview.SurferComponentPreview
import com.georgeci.moneysurfer.uikit.semantics.SurferSemantics
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import moneysurfer.uikit.generated.resources.Res
import moneysurfer.uikit.generated.resources.uikit_palette_color_amber
import moneysurfer.uikit.generated.resources.uikit_palette_color_green
import moneysurfer.uikit.generated.resources.uikit_palette_color_lime
import moneysurfer.uikit.generated.resources.uikit_palette_color_magenta
import moneysurfer.uikit.generated.resources.uikit_palette_color_red
import moneysurfer.uikit.generated.resources.uikit_palette_color_rose
import moneysurfer.uikit.generated.resources.uikit_palette_color_teal
import moneysurfer.uikit.generated.resources.uikit_palette_color_violet
import moneysurfer.uikit.generated.resources.uikit_palette_icon_card
import moneysurfer.uikit.generated.resources.uikit_palette_icon_cash
import moneysurfer.uikit.generated.resources.uikit_palette_icon_category
import moneysurfer.uikit.generated.resources.uikit_palette_icon_event
import moneysurfer.uikit.generated.resources.uikit_palette_icon_receipt
import moneysurfer.uikit.generated.resources.uikit_palette_icon_savings
import moneysurfer.uikit.generated.resources.uikit_palette_icon_tag
import moneysurfer.uikit.generated.resources.uikit_palette_icon_wallet
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
/**
* Grid of the palette icons, one selectable cell each.
*
* Cells are keyed by [SurferCategoryPalette.iconKeys] rather than by position, because the key is
* what gets stored — the grid never has to agree with the palette on what slot three means.
*
* Each cell is a `selectable` with the icon's own name, so a screen reader announces "Wallet,
* selected" instead of walking silently across eight unlabelled boxes.
*/
@Composable
fun SurferIconPickerGrid(
selectedKey: String,
tint: Color,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
columns: Int = DEFAULT_COLUMNS,
) {
val entries = SurferCategoryPalette.iconKeys.zip(SurferCategoryPalette.icons)
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(GridGap)) {
entries.chunked(columns).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(GridGap)) {
row.forEach { (key, icon) ->
IconCell(
icon = icon,
label = stringResource(iconLabelFor(key)),
selected = key == selectedKey,
tint = tint,
onClick = { onSelect(key) },
modifier = Modifier.weight(1f),
)
}
repeat(columns - row.size) {
Box(modifier = Modifier.weight(1f))
}
}
}
}
}
/** Row of the palette tints, one selectable swatch each. */
@Composable
fun SurferColorSwatchRow(
selectedHue: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(SwatchGap)) {
SurferCategoryPalette.hues.zip(SurferCategoryPalette.tints).forEach { (hue, color) ->
ColorSwatch(
color = color,
label = stringResource(colorLabelFor(hue)),
selected = hue == selectedHue,
onClick = { onSelect(hue) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun IconCell(
icon: ImageVector,
label: String,
selected: Boolean,
tint: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(CellCorner))
.background(if (selected) tint.copy(alpha = SelectedFill) else Color.Transparent)
.border(
if (selected) 2.dp else 1.dp,
if (selected) tint else AppTheme.materialColors.outlineVariant,
RoundedCornerShape(CellCorner),
)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = SurferSemantics.Decorative,
tint = if (selected) tint else AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.size(IconSize),
)
}
}
@Composable
private fun ColorSwatch(
color: Color,
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val ring = if (selected) {
Modifier
.border(3.dp, AppTheme.materialColors.surface, CircleShape)
.border(5.dp, color, CircleShape)
} else {
Modifier
}
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(color.copy(alpha = SwatchFill))
.then(ring)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
// The ring alone is easy to miss against a pale wash, and it is the only thing
// distinguishing the chosen colour from the seven beside it.
if (selected) {
Icon(
imageVector = SurferIcons.Check,
contentDescription = SurferSemantics.Decorative,
tint = color,
modifier = Modifier.size(CheckSize),
)
}
}
}
private const val DEFAULT_COLUMNS = 4
private const val SelectedFill = 0.15f
private const val SwatchFill = 0.18f
private val GridGap = 8.dp
private val SwatchGap = 6.dp
private val CellCorner = 14.dp
private val IconSize = 22.dp
private val CheckSize = 18.dp
/**
* Names in palette order, not keyed by icon key or hue value — the palette lists are already the
* single source of that ordering, and `CategoryPaletteParityTest` guards it against the domain.
* A lookup by position cannot drift from the swatch it labels.
*/
private val IconLabels: List<StringResource> = listOf(
Res.string.uikit_palette_icon_category,
Res.string.uikit_palette_icon_card,
Res.string.uikit_palette_icon_savings,
Res.string.uikit_palette_icon_cash,
Res.string.uikit_palette_icon_wallet,
Res.string.uikit_palette_icon_receipt,
Res.string.uikit_palette_icon_event,
Res.string.uikit_palette_icon_tag,
)
private val ColorLabels: List<StringResource> = listOf(
Res.string.uikit_palette_color_teal,
Res.string.uikit_palette_color_amber,
Res.string.uikit_palette_color_violet,
Res.string.uikit_palette_color_lime,
Res.string.uikit_palette_color_magenta,
Res.string.uikit_palette_color_rose,
Res.string.uikit_palette_color_red,
Res.string.uikit_palette_color_green,
)
private fun iconLabelFor(key: String): StringResource =
IconLabels.getOrElse(SurferCategoryPalette.iconKeys.indexOf(key)) { IconLabels.first() }
private fun colorLabelFor(hue: Int): StringResource =
ColorLabels.getOrElse(SurferCategoryPalette.hues.indexOf(hue)) { ColorLabels.first() }
@Preview
@Composable
private fun SurferCategoryAppearancePickerPreview() {
SurferComponentPreview {
Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.large)) {
SurferIconPickerGrid(
selectedKey = SurferCategoryPalette.iconKeys[1],
tint = SurferCategoryPalette.tints[1],
onSelect = {},
)
SurferColorSwatchRow(selectedHue = SurferCategoryPalette.hues[1], onSelect = {})
}
}
}