Coverage Summary for Class: BudgetDetailsScreenKt (com.georgeci.moneysurfer.feature.budget.details)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| BudgetDetailsScreenKt |
68.4%
(13/19)
|
26.4%
(24/91)
|
60.1%
(107/178)
|
49.8%
(856/1720)
|
| BudgetDetailsScreenKt$BudgetDetailsContent$3$2$1$5$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetDetailsScreenKt$BudgetDetailsContent$lambda$2$1$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| BudgetDetailsScreenKt$BudgetDetailsContent$lambda$2$1$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| BudgetDetailsScreenKt$BudgetDetailsContent$lambda$2$1$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| BudgetDetailsScreenKt$BudgetDetailsContent$lambda$2$1$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| BudgetDetailsScreenKt$WhenMappings |
|
| Total |
54.2%
(13/24)
|
26.4%
(24/91)
|
59.8%
(107/179)
|
49.5%
(856/1731)
|
package com.georgeci.moneysurfer.feature.budget.details
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.BudgetStatus
import com.georgeci.moneysurfer.domain.primitives.BudgetId
import com.georgeci.moneysurfer.domain.primitives.TransactionId
import com.georgeci.moneysurfer.feature.budget.BudgetUi
import com.georgeci.moneysurfer.feature.budget.budgetPeriodLabel
import com.georgeci.moneysurfer.feature.budget.budgetStatusLabel
import com.georgeci.moneysurfer.feature.budget.previewBudget
import com.georgeci.moneysurfer.feature.budget.toUi
import com.georgeci.moneysurfer.uikit.components.base.SurferPaneScaffold
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbarButtonAction
import com.georgeci.moneysurfer.uikit.components.budget.SurferAlertBanner
import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetRing
import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetStatusPill
import com.georgeci.moneysurfer.uikit.components.budget.SurferStatTile
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.HandleSideEffect
import moneysurfer.feature.budget.generated.resources.Res
import moneysurfer.feature.budget.generated.resources.budget_days_elapsed
import moneysurfer.feature.budget.generated.resources.budget_days_left
import moneysurfer.feature.budget.generated.resources.budget_details_alert_over_format
import moneysurfer.feature.budget.generated.resources.budget_details_alert_over_message
import moneysurfer.feature.budget.generated.resources.budget_details_alert_warn_format
import moneysurfer.feature.budget.generated.resources.budget_details_alert_warn_message_format
import moneysurfer.feature.budget.generated.resources.budget_details_edit
import moneysurfer.feature.budget.generated.resources.budget_details_not_found
import moneysurfer.feature.budget.generated.resources.budget_details_of_limit_format
import moneysurfer.feature.budget.generated.resources.budget_details_rollover_carry_format
import moneysurfer.feature.budget.generated.resources.budget_details_stat_daily_average
import moneysurfer.feature.budget.generated.resources.budget_details_stat_per_day
import moneysurfer.feature.budget.generated.resources.budget_details_stat_projected
import moneysurfer.feature.budget.generated.resources.budget_details_title
import moneysurfer.feature.budget.generated.resources.budget_details_transactions_empty
import moneysurfer.feature.budget.generated.resources.budget_details_transactions_header
import moneysurfer.feature.budget.generated.resources.budget_footer_format
import moneysurfer.feature.budget.generated.resources.budget_mixed_currency
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/** Stable selectors for the Budget details screen — see docs/testing/testing-strategy.md. */
object BudgetDetailsTestTags {
const val Root = "budgetDetails:root"
const val Ring = "budgetDetails:ring"
}
@Composable
fun BudgetDetailsScreen(
budgetId: BudgetId,
onNavigateBack: () -> Unit,
onNavigateToBudgetEdit: (BudgetId) -> Unit,
onNavigateToTransactionDetails: (TransactionId) -> Unit,
viewModel: BudgetDetailsViewModel = koinViewModel(key = budgetId.value) { parametersOf(budgetId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
BudgetDetailsEffect.NavigateBack -> onNavigateBack()
is BudgetDetailsEffect.NavigateToBudgetEdit -> onNavigateToBudgetEdit(effect.budgetId)
is BudgetDetailsEffect.NavigateToTransactionDetails ->
onNavigateToTransactionDetails(effect.transactionId)
}
}
BudgetDetailsContent(state = state, onEvent = viewModel::onEvent)
}
/**
* Stateless half of the screen — public so `:composeApp` desktop UI tests can mount it with an
* injected state, the way `DashboardContent` is used.
*/
@Composable
fun BudgetDetailsContent(
state: BudgetDetailsState,
onEvent: (BudgetDetailsEvent) -> Unit,
) {
val content = state as? BudgetDetailsState.Content
SurferPaneScaffold(
title = content?.budget?.name ?: stringResource(Res.string.budget_details_title),
modifier = Modifier
.testTag(BudgetDetailsTestTags.Root)
.surferTestTagAsId(),
onBack = { onEvent(BudgetDetailsEvent.OnBackClick) },
actions = {
if (content != null) {
SurferToolbarButtonAction(
icon = SurferIcons.Edit,
text = stringResource(Res.string.budget_details_edit),
onClick = { onEvent(BudgetDetailsEvent.OnEditClick) },
)
}
},
) { padding ->
if (state is BudgetDetailsState.Missing) {
Box(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(padding)
.padding(AppTheme.spacing.large),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.budget_details_not_found),
style = AppTheme.typography.bodyLarge,
color = AppTheme.materialColors.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
return@SurferPaneScaffold
}
if (content == null) {
Box(modifier = Modifier.fillMaxSize().padding(padding))
return@SurferPaneScaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize().surferContentContainer().padding(top = padding.calculateTopPadding()),
contentPadding = PaddingValues(
start = AppTheme.spacing.default,
end = AppTheme.spacing.default,
top = AppTheme.spacing.medium,
bottom = padding.calculateBottomPadding() + AppTheme.spacing.xLarge,
),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
item(key = "hero") { BudgetHero(content.budget) }
item(key = "alert") { BudgetAlert(content.budget) }
item(key = "stats") { BudgetStats(content.budget) }
item(key = "transactionsHeader") {
Text(
text = stringResource(Res.string.budget_details_transactions_header),
style = AppTheme.typography.titleSmall,
color = AppTheme.materialColors.onSurface,
modifier = Modifier.padding(top = AppTheme.spacing.small),
)
}
if (content.transactions.isEmpty()) {
item(key = "transactionsEmpty") {
Text(
text = stringResource(Res.string.budget_details_transactions_empty),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
}
items(content.transactions, key = { it.id.value }) { transaction ->
BudgetTransactionRow(
transaction = transaction,
onClick = { onEvent(BudgetDetailsEvent.OnTransactionClick(transaction.id)) },
)
}
}
}
}
@Composable
private fun BudgetHero(budget: BudgetUi) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
) {
SurferBudgetRing(
progress = budget.progress,
status = budget.status.toUi(),
modifier = Modifier.testTag(BudgetDetailsTestTags.Ring),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${budget.percent}%",
style = AppTheme.typography.headlineMedium,
color = AppTheme.materialColors.onSurface,
)
Text(
text = budget.spentFormatted,
style = AppTheme.typography.titleSmall,
color = AppTheme.materialColors.onSurface,
)
Text(
text = stringResource(Res.string.budget_details_of_limit_format, budget.limitFormatted),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
}
SurferBudgetStatusPill(label = budgetStatusLabel(budget.status), status = budget.status.toUi())
Text(
text = stringResource(
Res.string.budget_footer_format,
"${budgetPeriodLabel(budget.period)} · ${budget.windowLabel}",
pluralStringResource(Res.plurals.budget_days_left, budget.daysLeft, budget.daysLeft),
),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
budget.rolloverCarryFormatted?.let { carry ->
Text(
text = stringResource(Res.string.budget_details_rollover_carry_format, carry),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
if (budget.hasMixedCurrency) {
Text(
text = stringResource(Res.string.budget_mixed_currency),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun BudgetAlert(budget: BudgetUi) {
when (budget.status) {
BudgetStatus.OK -> Unit
BudgetStatus.WARN -> SurferAlertBanner(
title = stringResource(Res.string.budget_details_alert_warn_format, budget.percent),
status = budget.status.toUi(),
message = stringResource(
Res.string.budget_details_alert_warn_message_format,
budget.alertPercent,
),
)
BudgetStatus.OVER -> SurferAlertBanner(
title = stringResource(Res.string.budget_details_alert_over_format, budget.remainderFormatted),
status = budget.status.toUi(),
message = stringResource(Res.string.budget_details_alert_over_message),
)
}
}
@Composable
private fun BudgetStats(budget: BudgetUi) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
) {
SurferStatTile(
label = stringResource(Res.string.budget_details_stat_daily_average),
value = budget.dailyAverageFormatted,
caption = pluralStringResource(
Res.plurals.budget_days_elapsed,
budget.elapsedDays,
budget.elapsedDays,
),
modifier = Modifier.weight(1f),
)
SurferStatTile(
label = stringResource(Res.string.budget_details_stat_projected),
value = budget.projectedTotalFormatted,
valueColor = if (budget.status == BudgetStatus.OVER) AppTheme.materialColors.error else null,
modifier = Modifier.weight(1f),
)
SurferStatTile(
label = stringResource(Res.string.budget_details_stat_per_day),
value = budget.perDayRemainingFormatted,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun BudgetTransactionRow(
transaction: BudgetTransactionUi,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = AppTheme.spacing.small),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = transaction.title,
style = AppTheme.typography.bodyLarge,
color = AppTheme.materialColors.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(transaction.dateLabel, transaction.categoryName).joinToString(" · "),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
Text(
text = transaction.amountFormatted,
style = AppTheme.typography.titleSmall,
color = AppTheme.materialColors.onSurface,
)
}
}
@Preview
@Composable
private fun BudgetDetailsContentPreview() {
AppTheme {
BudgetDetailsContent(
state = BudgetDetailsState.Content(
budget = previewBudget(),
transactions = listOf(
BudgetTransactionUi(
id = TransactionId("t-1"),
title = "Lidl",
categoryName = "Groceries",
amountFormatted = "€42.10",
dateLabel = "18 Apr",
),
),
),
onEvent = {},
)
}
}