Coverage Summary for Class: SurferGoalsWidgetKt (com.georgeci.moneysurfer.uikit.widgets)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SurferGoalsWidgetKt |
100%
(1/1)
|
75%
(6/8)
|
50%
(28/56)
|
97.5%
(79/81)
|
90.8%
(814/896)
|
package com.georgeci.moneysurfer.uikit.widgets
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.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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.georgeci.moneysurfer.uikit.components.goal.SurferGoalRingArc
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.preview.SurferComponentPreview
import com.georgeci.moneysurfer.uikit.theme.AppTheme
data class SurferGoalItem(
val id: String,
val name: String,
val savedFormatted: String,
val targetFormatted: String,
val progress: Float,
val captionLine: String,
)
/**
* Goals widget for the dashboard column.
*
* [seeAllTestTag] tags the "see all" link so a UI test can tap it without matching localized
* copy; the host screen owns the tag value because the link is a step in its flow.
*/
@Composable
fun SurferGoalsWidget(
items: List<SurferGoalItem>,
title: String,
seeAllLabel: String,
onSeeAllClick: () -> Unit,
modifier: Modifier = Modifier,
size: SurferWidgetSize = LocalSurferWidgetSize.current,
onItemClick: ((SurferGoalItem) -> Unit)? = null,
emptyTitle: String? = null,
emptySubtitle: String? = null,
seeAllTestTag: String? = null,
) {
val hero = size == SurferWidgetSize.Expanded
val visibleItems = if (hero) items.take(2) else items.take(1)
SurferWidgetCard(
title = title,
modifier = modifier,
trailing = {
Text(
text = seeAllLabel,
style = AppTheme.typography.labelMedium,
color = AppTheme.materialColors.primary,
modifier = Modifier
.clickable(onClick = onSeeAllClick)
.then(seeAllTestTag?.let { Modifier.testTag(it) } ?: Modifier),
)
},
) {
if (items.isEmpty()) {
SurferWidgetEmptyState(
icon = SurferIcons.Savings,
title = emptyTitle,
subtitle = emptySubtitle,
)
return@SurferWidgetCard
}
Spacer(Modifier.height(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
visibleItems.forEach { item ->
GoalRow(
item = item,
onClick = onItemClick?.let { handler -> { handler(item) } },
)
}
}
}
}
@Composable
private fun GoalRow(item: SurferGoalItem, onClick: (() -> Unit)?) {
val rowModifier = if (onClick != null) {
Modifier.fillMaxWidth().clickable(onClick = onClick)
} else {
Modifier.fillMaxWidth()
}
Row(
modifier = rowModifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
ProgressRing(progress = item.progress, size = 56.dp)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.name,
style = AppTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = AppTheme.materialColors.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.size(8.dp))
Text(
text = "${item.savedFormatted} / ${item.targetFormatted}",
style = AppTheme.typography.labelLarge,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = item.captionLine,
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* The compact dashboard variant of the details-screen hero: same arc, percent only.
* The arc itself lives in `components/goal` so the two rings cannot drift apart.
*/
@Composable
private fun ProgressRing(
progress: Float,
size: Dp,
) {
val percentText = "${(progress.coerceIn(0f, 1f) * PERCENT_SCALE).toInt()}%"
Box(modifier = Modifier.size(size), contentAlignment = Alignment.Center) {
SurferGoalRingArc(progress = progress, size = size)
Text(
text = percentText,
style = AppTheme.typography.labelMedium,
color = AppTheme.materialColors.onSurface,
)
}
}
private const val PERCENT_SCALE = 100
@Preview
@Composable
private fun SurferGoalsWidgetHeroPreview() {
SurferComponentPreview {
Box(modifier = Modifier.padding(16.dp)) {
SurferGoalsWidget(
items = listOf(
SurferGoalItem(
id = "1",
name = "Emergency fund",
savedFormatted = "€8,915",
targetFormatted = "€12,000",
progress = 0.74f,
captionLine = "€3,085 left · €770/mo to hit Aug 2026",
),
SurferGoalItem(
id = "2",
name = "Lisbon trip",
savedFormatted = "€1,480",
targetFormatted = "€2,400",
progress = 0.62f,
captionLine = "€920 left · €230/mo to hit Sep 2026",
),
),
title = "Goals",
seeAllLabel = "See all",
onSeeAllClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Preview
@Composable
private fun SurferGoalsWidgetEmptyPreview() {
SurferComponentPreview {
Box(modifier = Modifier.padding(16.dp)) {
SurferGoalsWidget(
items = emptyList(),
title = "Goals",
seeAllLabel = "See all",
onSeeAllClick = {},
emptyTitle = "No goals yet",
emptySubtitle = "Set a savings target to track progress here.",
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
)
}
}
}