Coverage Summary for Class: DashboardCardStyleSheetKt (com.georgeci.moneysurfer.feature.dashboard.customize)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| DashboardCardStyleSheetKt |
100%
(1/1)
|
92.9%
(13/14)
|
65%
(65/100)
|
100%
(188/188)
|
97.8%
(1971/2015)
|
package com.georgeci.moneysurfer.feature.dashboard.customize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
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.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.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.clearAndSetSemantics
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.domain.dashboard.DashboardCardStyle
import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutConfig
import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutItem
import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetSize
import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetSpan
import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetType
import com.georgeci.moneysurfer.uikit.components.SurferBottomSheetContent
import com.georgeci.moneysurfer.uikit.components.base.SurferSectionLabel
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.feature.dashboard.generated.resources.Res
import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_style_size
import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_style_span
import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_style_subtitle
import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_style_variant
import org.jetbrains.compose.resources.stringResource
/**
* Height every thumbnail is cropped to, so the tiles in a row line up whatever the widget's
* natural height is. Sized to the tallest widget at [VARIANT_TILE_WIDTH]; a shorter card simply
* leaves the rest of the tile empty rather than being blown up to fill it.
*/
private val PREVIEW_HEIGHT = 104.dp
/** Width of a variant tile. Fixed rather than shared evenly: the variant row scrolls. */
private val VARIANT_TILE_WIDTH = 150.dp
/** Width of a grid-width tile. Fixed for the same reason as [VARIANT_TILE_WIDTH]: the row scrolls. */
private val SPAN_TILE_WIDTH = 124.dp
/** Height of one cell in a grid-width tile's schematic row. */
private val SPAN_SCHEMATIC_HEIGHT = 22.dp
private val TILE_SHAPE = RoundedCornerShape(18.dp)
private val SHEET_PADDING = 20.dp
/**
* The card-style picker for one widget. The surrounding modal is here rather than in the caller
* because the sheet is opened from a list row, not from a navigation route.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DashboardCardStyleSheet(
item: DashboardLayoutItem,
onSelect: (DashboardCardStyle) -> Unit,
onSpanSelect: (DashboardWidgetSpan) -> Unit,
onDismiss: () -> Unit,
styleEnabled: Boolean = true,
spanEnabled: Boolean = false,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = AppTheme.materialColors.surfaceContainerLow,
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
) {
DashboardCardStyleSheetContent(
item = item,
onSelect = onSelect,
onSpanSelect = onSpanSelect,
styleEnabled = styleEnabled,
spanEnabled = spanEnabled,
)
}
}
/**
* Stateless half of the picker — public so `:composeApp` desktop UI tests can mount it directly,
* the way [DashboardCustomizeContent] is mounted, without going through the modal's animation.
*
* Every style tile is a real render of the widget at the style it offers, side by side, because the
* choice is a visual one: "Compact" and "Inline" mean nothing as words in a list.
*
* The two halves are gated independently, because they are governed by different things.
* [styleEnabled] is the host's `dashboard_widget_style` build key — the card-style work is
* unfinished and ships off. [spanEnabled] is the window: the dashboard only lays widgets out in a
* grid from expanded width up, so on a phone a width control would change nothing visible, which
* reads as a broken one. The caller opens this sheet when *either* is on, and a sheet with both off
* has nothing to offer and is never opened.
*/
@Composable
fun DashboardCardStyleSheetContent(
item: DashboardLayoutItem,
onSelect: (DashboardCardStyle) -> Unit,
onSpanSelect: (DashboardWidgetSpan) -> Unit,
modifier: Modifier = Modifier,
styleEnabled: Boolean = true,
spanEnabled: Boolean = false,
) {
val variants = item.type.variantOptions()
SurferBottomSheetContent(
title = stringResource(item.type.titleResource()),
subtitle = stringResource(Res.string.dashboard_customize_style_subtitle),
modifier = modifier.testTag(DashboardCustomizeTestTags.styleSheet(item.type.name)),
) {
if (styleEnabled) {
SurferSectionLabel(
text = stringResource(Res.string.dashboard_customize_style_size),
modifier = Modifier.padding(horizontal = SHEET_PADDING),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SHEET_PADDING),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DashboardWidgetSize.entries.forEach { size ->
val style = item.cardStyle.copy(size = size)
StyleOptionTile(
label = stringResource(size.labelResource()),
selected = item.cardStyle.size == size,
tag = DashboardCustomizeTestTags.styleOption(item.type.name, size.name),
onClick = { onSelect(style) },
modifier = Modifier.weight(1f),
) {
DashboardWidgetPreview(type = item.type, cardStyle = style)
}
}
}
}
if (styleEnabled && variants.isNotEmpty()) {
val selectedKey = item.type.selectedVariant(item.cardStyle)?.key
SurferSectionLabel(
text = stringResource(Res.string.dashboard_customize_style_variant),
modifier = Modifier.padding(top = 8.dp, start = SHEET_PADDING, end = SHEET_PADDING),
)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = SHEET_PADDING),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
variants.forEach { variant ->
val style = item.cardStyle.copy(variant = variant.key)
StyleOptionTile(
label = stringResource(variant.label),
selected = selectedKey == variant.key,
tag = DashboardCustomizeTestTags.styleOption(item.type.name, variant.key),
onClick = { onSelect(style) },
modifier = Modifier.width(VARIANT_TILE_WIDTH),
) {
DashboardWidgetPreview(type = item.type, cardStyle = style)
}
}
}
}
if (spanEnabled) {
SurferSectionLabel(
text = stringResource(Res.string.dashboard_customize_style_span),
modifier = Modifier.padding(top = 8.dp, start = SHEET_PADDING, end = SHEET_PADDING),
)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = SHEET_PADDING),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DashboardWidgetSpan.entries.forEach { span ->
SpanOptionTile(
span = span,
selected = item.span == span,
onClick = { onSpanSelect(span) },
tag = DashboardCustomizeTestTags.spanOption(item.type.name, span.name),
)
}
}
}
}
}
/**
* One grid width, drawn as the row it produces: [DashboardWidgetSpan.COLUMNS] cells with the ones
* this span claims filled in. A schematic rather than a real widget render, because what the choice
* changes is the *row* — the same card at a third of the width is a thumbnail of the neighbours it
* would have, which is exactly what the size tiles above already show.
*/
@Composable
private fun SpanOptionTile(
span: DashboardWidgetSpan,
selected: Boolean,
onClick: () -> Unit,
tag: String,
modifier: Modifier = Modifier,
) {
val colors = AppTheme.materialColors
val outline = if (selected) colors.primary else colors.outlineVariant
val label = stringResource(span.labelResource())
Box(
modifier = modifier
.width(SPAN_TILE_WIDTH)
.clip(TILE_SHAPE)
.border(if (selected) 2.dp else 1.dp, outline, TILE_SHAPE),
) {
Column(
// Same reason as [StyleOptionTile]: the schematic is drawing, and the label below it is
// already the name of the tap target laid over everything.
modifier = Modifier
.padding(10.dp)
.clearAndSetSemantics {},
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SpanSchematic(
claimedColumns = span.columns,
claimed = if (selected) colors.primary else colors.onSurfaceVariant,
free = colors.surfaceContainerHighest,
)
Text(
text = label,
style = AppTheme.typography.labelLarge,
color = if (selected) colors.onSurface else colors.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.matchParentSize()
.selectable(selected = selected, onClick = onClick)
.semantics { contentDescription = label }
.testTag(tag),
)
}
}
/**
* A dashboard row in miniature: the first [claimedColumns] of [DashboardWidgetSpan.COLUMNS] cells
* drawn in [claimed], the rest left [free]. Which cells are claimed is the whole of what a width
* choice changes, so the tile shows the row rather than the card.
*/
@Composable
private fun SpanSchematic(claimedColumns: Int, claimed: Color, free: Color) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
repeat(DashboardWidgetSpan.COLUMNS) { column ->
Box(
modifier = Modifier
.weight(1f)
.height(SPAN_SCHEMATIC_HEIGHT)
.clip(AppTheme.shapes.extraSmall)
.background(if (column < claimedColumns) claimed else free),
)
}
}
}
@Composable
private fun StyleOptionTile(
label: String,
selected: Boolean,
tag: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
preview: @Composable () -> Unit,
) {
val outline = if (selected) AppTheme.materialColors.primary else AppTheme.materialColors.outlineVariant
Box(
modifier = modifier
.clip(TILE_SHAPE)
.border(if (selected) 2.dp else 1.dp, outline, TILE_SHAPE),
) {
Column(
// The whole tile is drawing, not reading matter: the thumbnail's sample figures must
// not be read out as if they were the user's, and the label below it is already the
// name of the tap target laid over everything. Announcing both says it twice.
modifier = Modifier
.padding(10.dp)
.clearAndSetSemantics {},
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(PREVIEW_HEIGHT)
.clipToBounds(),
contentAlignment = Alignment.TopStart,
) {
preview()
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
style = AppTheme.typography.labelLarge,
color = if (selected) {
AppTheme.materialColors.onSurface
} else {
AppTheme.materialColors.onSurfaceVariant
},
modifier = Modifier.weight(1f),
)
if (selected) {
Icon(
imageVector = SurferIcons.Check,
contentDescription = SurferSemantics.Decorative,
tint = AppTheme.materialColors.primary,
modifier = Modifier.size(16.dp),
)
}
}
}
// The tap target sits above the thumbnail: the sample widget carries its own clickable
// affordances ("See all", "Add account") that would otherwise swallow the tap.
Box(
modifier = Modifier
.matchParentSize()
.selectable(selected = selected, onClick = onClick)
.semantics { contentDescription = label }
.testTag(tag),
)
}
}
@Preview
@Composable
private fun DashboardCardStyleSheetPreview() {
SurferComponentPreview {
DashboardCardStyleSheetContent(
item = DashboardLayoutConfig.DEFAULT.items.first { it.type == DashboardWidgetType.Balance },
onSelect = {},
onSpanSelect = {},
spanEnabled = true,
)
}
}