Coverage Summary for Class: SurferInsightsVariant (com.georgeci.moneysurfer.uikit.widgets)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SurferInsightsVariant |
100%
(1/1)
|
|
100%
(2/2)
|
100%
(23/23)
|
| SurferInsightsVariant$Companion |
100%
(1/1)
|
100%
(2/2)
|
100%
(1/1)
|
100%
(14/14)
|
| Total |
100%
(2/2)
|
100%
(2/2)
|
100%
(3/3)
|
100%
(37/37)
|
package com.georgeci.moneysurfer.uikit.widgets
import androidx.compose.foundation.background
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.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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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
enum class SurferInsightTone { Good, Warn, Neutral }
data class SurferInsightItem(
val id: String,
val tone: SurferInsightTone,
val icon: ImageVector,
val title: String,
val body: String,
)
/**
* How the cards are arranged inside the widget.
*
* The entry names double as the persisted keys of a dashboard card style, which is why [fromKey]
* is lenient: a layout written by a newer build may name a layout this one has never heard of, and
* falling back to [List] beats refusing to draw the insights.
*/
enum class SurferInsightsVariant {
/** Stacked cards, as many as the card size has room for — the dashboard default. */
List,
/** One card per page, swiped sideways, with a dot per insight underneath. */
Carousel,
;
companion object {
fun fromKey(key: String?): SurferInsightsVariant = entries.firstOrNull { it.name == key } ?: List
}
}
/**
* The generated-insight card.
*
* [SurferInsightsVariant.List] keeps as many cards as the size allows — four expanded, one
* compact — because a column of insights is read at a glance. [SurferInsightsVariant.Carousel]
* keeps all of them instead and trades height for swipes, which is what makes it worth offering:
* a compact carousel still reaches every insight.
*
* There is no "N new" badge slot. Counting new findings needs seen-or-dismissed state that
* nothing persists yet (see `Insight.id` for the hook it would hang on), so the parameter was
* only ever rendering `items.size` — and an eighth parameter trips SonarCloud's `kotlin:S107`,
* which `@Composable` hides from detekt. It comes back with the feature that needs it.
*/
@Composable
fun SurferInsightsWidget(
items: List<SurferInsightItem>,
title: String,
modifier: Modifier = Modifier,
size: SurferWidgetSize = LocalSurferWidgetSize.current,
variant: SurferInsightsVariant = SurferInsightsVariant.List,
onItemClick: ((SurferInsightItem) -> Unit)? = null,
emptyText: String? = null,
) {
SurferWidgetCard(
title = title,
modifier = modifier,
) {
if (items.isEmpty()) {
SurferWidgetEmptyState(
icon = SurferIcons.Sparkle,
title = null,
subtitle = emptyText,
)
return@SurferWidgetCard
}
Spacer(Modifier.height(10.dp))
when (variant) {
SurferInsightsVariant.List -> InsightList(
items = items.take(if (size == SurferWidgetSize.Expanded) EXPANDED_ROWS else COMPACT_ROWS),
onItemClick = onItemClick,
)
SurferInsightsVariant.Carousel -> InsightCarousel(items = items, onItemClick = onItemClick)
}
}
}
@Composable
private fun InsightList(
items: List<SurferInsightItem>,
onItemClick: ((SurferInsightItem) -> Unit)?,
) {
Column(
modifier = Modifier.padding(horizontal = CARD_INSET),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items.forEach { item ->
InsightCard(
item = item,
onClick = onItemClick?.let { handler -> { handler(item) } },
)
}
}
}
/**
* One card per page plus a dot row.
*
* Every page pins its title and body to a fixed number of lines so the pager keeps one height
* across the whole set: a lazy row of pages is measured from what is composed, so pages of
* different heights make the card grow and shrink under the reader's thumb mid-swipe.
*/
@Composable
private fun InsightCarousel(
items: List<SurferInsightItem>,
onItemClick: ((SurferInsightItem) -> Unit)?,
) {
val pagerState = rememberPagerState(pageCount = { items.size })
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = CARD_INSET),
pageSpacing = 8.dp,
) { page ->
val item = items[page]
InsightCard(
item = item,
onClick = onItemClick?.let { handler -> { handler(item) } },
pinHeight = true,
)
}
if (items.size > 1) {
Spacer(Modifier.height(10.dp))
PageDots(count = items.size, selected = pagerState.currentPage)
}
}
@Composable
private fun PageDots(count: Int, selected: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(count) { index ->
val active = index == selected
Box(
modifier = Modifier
.size(if (active) 8.dp else 6.dp)
.clip(CircleShape)
.background(
if (active) {
AppTheme.materialColors.primary
} else {
AppTheme.materialColors.surfaceContainerHighest
},
),
)
}
}
}
@Composable
private fun InsightCard(
item: SurferInsightItem,
onClick: (() -> Unit)?,
/**
* Pins the card to one height: a single title line and exactly [CAROUSEL_BODY_LINES] body
* lines. Both halves have to be pinned — capping only the body still lets a long category name
* wrap the title onto a second line, which is the resize this exists to prevent.
*/
pinHeight: Boolean = false,
) {
val (bg, fg) = when (item.tone) {
SurferInsightTone.Good ->
AppTheme.materialColors.tertiaryContainer to
AppTheme.materialColors.onTertiaryContainer
SurferInsightTone.Warn ->
AppTheme.materialColors.errorContainer to
AppTheme.materialColors.onErrorContainer
SurferInsightTone.Neutral ->
AppTheme.materialColors.surfaceContainerHigh to
AppTheme.materialColors.onSurface
}
val rowModifier = if (onClick != null) {
Modifier.fillMaxWidth().clickable(onClick = onClick)
} else {
Modifier.fillMaxWidth()
}
Row(
modifier = rowModifier
.clip(RoundedCornerShape(14.dp))
.background(bg)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.25f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = item.icon,
contentDescription = SurferSemantics.Decorative,
tint = fg,
modifier = Modifier.size(20.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = AppTheme.typography.titleSmall,
color = fg,
maxLines = if (pinHeight) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = item.body,
style = AppTheme.typography.bodySmall,
color = fg.copy(alpha = 0.85f),
minLines = if (pinHeight) CAROUSEL_BODY_LINES else 1,
maxLines = if (pinHeight) CAROUSEL_BODY_LINES else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Side inset of the cards inside the widget — a touch tighter than the header's 16dp. */
private val CARD_INSET = 14.dp
/**
* Four, not three: the dashboard's engine emits at most four insights and sorts the neutral ones
* last, so a three-row cap silently dropped the subscription count whenever the two category rules
* and the period rule all fired — which is most months. A cap that hides a whole rule in the
* default card style is worse than one more row on the hero card.
*/
private const val EXPANDED_ROWS = 4
private const val COMPACT_ROWS = 1
/** Two lines fit the generated copy; see [InsightCarousel] for why it is pinned rather than capped. */
private const val CAROUSEL_BODY_LINES = 2
private val PREVIEW_ITEMS = listOf(
SurferInsightItem(
id = "1",
tone = SurferInsightTone.Warn,
icon = SurferIcons.ArrowUp,
title = "Dining is up 28%",
body = "€162 spent — €35 above your usual €127 / month.",
),
SurferInsightItem(
id = "2",
tone = SurferInsightTone.Good,
icon = SurferIcons.Sparkle,
title = "On track to save €420",
body = "You spent 21% less on Leisure than last month.",
),
SurferInsightItem(
id = "3",
tone = SurferInsightTone.Neutral,
icon = SurferIcons.Sync,
title = "4 active subscriptions",
body = "About €62 a month.",
),
)
@Preview
@Composable
private fun SurferInsightsWidgetHeroPreview() {
SurferComponentPreview {
Box(modifier = Modifier.padding(16.dp)) {
SurferInsightsWidget(
items = PREVIEW_ITEMS,
title = "Insights",
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Preview
@Composable
private fun SurferInsightsWidgetCarouselPreview() {
SurferComponentPreview {
Box(modifier = Modifier.padding(16.dp)) {
SurferInsightsWidget(
items = PREVIEW_ITEMS,
title = "Insights",
variant = SurferInsightsVariant.Carousel,
size = SurferWidgetSize.Compact,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Preview
@Composable
private fun SurferInsightsWidgetEmptyPreview() {
SurferComponentPreview {
Box(modifier = Modifier.padding(16.dp)) {
SurferInsightsWidget(
items = emptyList(),
title = "Insights",
emptyText = "Nothing notable this period.",
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
)
}
}
}