Coverage Summary for Class: BudgetsScreenKt (com.georgeci.moneysurfer.feature.budget.list)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| BudgetsScreenKt |
0%
(0/8)
|
0%
(0/56)
|
0%
(0/78)
|
0%
(0/956)
|
| BudgetsScreenKt$BudgetsContent$3$2$1$2$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetsScreenKt$BudgetsContent$3$2$1$2$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetsScreenKt$BudgetsContent$3$2$1$2$3$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetsScreenKt$BudgetsContent$3$2$1$4$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetsScreenKt$BudgetsContent$3$2$1$4$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetsScreenKt$BudgetsContent$3$2$1$4$3$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/11)
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$1 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$2 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$3 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$4 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$5 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$6 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$7 |
0%
(0/1)
|
|
| BudgetsScreenKt$BudgetsContent$lambda$2$1$0$$inlined$items$default$8 |
0%
(0/1)
|
|
| Total |
0%
(0/22)
|
0%
(0/56)
|
0%
(0/84)
|
0%
(0/1022)
|
package com.georgeci.moneysurfer.feature.budget.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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.AlertDialog
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.primitives.BudgetId
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.SurferCategoryPalette
import com.georgeci.moneysurfer.uikit.components.base.SurferAddFab
import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeader
import com.georgeci.moneysurfer.uikit.components.base.SurferSwipeAction
import com.georgeci.moneysurfer.uikit.components.base.SurferSwipeRevealRow
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetCard
import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetCardMetrics
import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetCategoryVisual
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
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_left
import moneysurfer.feature.budget.generated.resources.budget_footer_format
import moneysurfer.feature.budget.generated.resources.budget_over_format
import moneysurfer.feature.budget.generated.resources.budget_remaining_format
import moneysurfer.feature.budget.generated.resources.budget_spent_of_format
import moneysurfer.feature.budget.generated.resources.budgets_active_header
import moneysurfer.feature.budget.generated.resources.budgets_add
import moneysurfer.feature.budget.generated.resources.budgets_archive
import moneysurfer.feature.budget.generated.resources.budgets_archived_header
import moneysurfer.feature.budget.generated.resources.budgets_delete
import moneysurfer.feature.budget.generated.resources.budgets_delete_cancel
import moneysurfer.feature.budget.generated.resources.budgets_delete_message
import moneysurfer.feature.budget.generated.resources.budgets_delete_title
import moneysurfer.feature.budget.generated.resources.budgets_empty
import moneysurfer.feature.budget.generated.resources.budgets_restore
import moneysurfer.feature.budget.generated.resources.budgets_title
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
/** Stable selectors for the Budgets screen — see docs/testing/testing-strategy.md. */
object BudgetsTestTags {
const val Root = "budgets:root"
const val AddButton = "budgets:add"
const val List = "budgets:list"
}
@Composable
fun BudgetsScreen(
onNavigateBack: () -> Unit,
onNavigateToBudgetCreation: () -> Unit,
onNavigateToBudgetDetails: (BudgetId) -> Unit,
viewModel: BudgetsViewModel = koinViewModel(),
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
BudgetsEffect.NavigateBack -> onNavigateBack()
BudgetsEffect.NavigateToBudgetCreation -> onNavigateToBudgetCreation()
is BudgetsEffect.NavigateToBudgetDetails -> onNavigateToBudgetDetails(effect.budgetId)
}
}
BudgetsContent(
state = state,
onEvent = viewModel::onEvent,
)
}
@Composable
private fun BudgetsContent(
state: BudgetsState,
onEvent: (BudgetsEvent) -> Unit,
) {
Scaffold(
modifier = Modifier
.surferSafeInsets()
.testTag(BudgetsTestTags.Root)
.surferTestTagAsId(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(Res.string.budgets_title),
onBack = { onEvent(BudgetsEvent.OnBackClick) },
)
},
floatingActionButton = {
val addLabel = stringResource(Res.string.budgets_add)
SurferAddFab(
label = addLabel,
onClick = { onEvent(BudgetsEvent.OnAddBudgetClick) },
modifier = Modifier.testTag(BudgetsTestTags.AddButton),
)
},
) { padding ->
val content = state as? BudgetsState.Content
if (content == null) {
Box(modifier = Modifier.fillMaxSize().padding(padding))
return@Scaffold
}
if (content.active.isEmpty() && content.archived.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(padding)
.padding(horizontal = AppTheme.spacing.large),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.budgets_empty),
style = AppTheme.typography.bodyLarge,
color = AppTheme.materialColors.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding())
.testTag(BudgetsTestTags.List),
contentPadding = PaddingValues(
top = AppTheme.spacing.small,
bottom = padding.calculateBottomPadding() +
AppTheme.spacing.xxxLarge + AppTheme.spacing.xLarge,
),
) {
if (content.active.isNotEmpty() && content.archived.isNotEmpty()) {
item(key = "header:active") {
SurferSectionHeader(
title = stringResource(Res.string.budgets_active_header),
modifier = Modifier.padding(
start = AppTheme.spacing.default,
end = AppTheme.spacing.default,
top = AppTheme.spacing.medium,
bottom = AppTheme.spacing.xSmall,
),
)
}
}
items(content.active, key = { it.id.value }) { budget ->
BudgetRow(
budget = budget,
archiveLabel = stringResource(Res.string.budgets_archive),
deleteLabel = stringResource(Res.string.budgets_delete),
onClick = { onEvent(BudgetsEvent.OnBudgetClick(budget.id)) },
onArchiveClick = { onEvent(BudgetsEvent.OnArchiveClick(budget.id)) },
onDeleteClick = { onEvent(BudgetsEvent.OnDeleteClick(budget.id)) },
)
}
if (content.archived.isNotEmpty()) {
item(key = "header:archived") {
SurferSectionHeader(
title = stringResource(Res.string.budgets_archived_header),
modifier = Modifier.padding(
start = AppTheme.spacing.default,
end = AppTheme.spacing.default,
top = AppTheme.spacing.medium,
bottom = AppTheme.spacing.xSmall,
),
)
}
items(content.archived, key = { it.id.value }) { budget ->
BudgetRow(
budget = budget,
archiveLabel = stringResource(Res.string.budgets_restore),
deleteLabel = stringResource(Res.string.budgets_delete),
onClick = { onEvent(BudgetsEvent.OnBudgetClick(budget.id)) },
onArchiveClick = { onEvent(BudgetsEvent.OnRestoreClick(budget.id)) },
onDeleteClick = { onEvent(BudgetsEvent.OnDeleteClick(budget.id)) },
)
}
}
}
}
(state as? BudgetsState.Content)?.pendingDelete?.let { pending ->
AlertDialog(
onDismissRequest = { onEvent(BudgetsEvent.OnDeleteCancel) },
title = { Text(stringResource(Res.string.budgets_delete_title)) },
text = { Text(stringResource(Res.string.budgets_delete_message, pending.name)) },
confirmButton = {
TextButton(onClick = { onEvent(BudgetsEvent.OnDeleteConfirm) }) {
Text(stringResource(Res.string.budgets_delete))
}
},
dismissButton = {
TextButton(onClick = { onEvent(BudgetsEvent.OnDeleteCancel) }) {
Text(stringResource(Res.string.budgets_delete_cancel))
}
},
)
}
}
@Composable
private fun BudgetRow(
budget: BudgetUi,
archiveLabel: String,
deleteLabel: String,
onClick: () -> Unit,
onArchiveClick: () -> Unit,
onDeleteClick: () -> Unit,
) {
SurferSwipeRevealRow(
revealWidth = 176.dp,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppTheme.spacing.default)
.padding(vertical = AppTheme.spacing.xSmall),
actions = {
SurferSwipeAction(
icon = if (budget.isActive) SurferIcons.Archive else SurferIcons.Restore,
label = archiveLabel,
onClick = onArchiveClick,
)
SurferSwipeAction(
icon = SurferIcons.Delete,
label = deleteLabel,
onClick = onDeleteClick,
destructive = true,
)
},
) {
SurferBudgetCard(
name = budget.name,
statusLabel = budgetStatusLabel(budget.status),
status = budget.status.toUi(),
metrics = SurferBudgetCardMetrics(
spentOfLimit = stringResource(
Res.string.budget_spent_of_format,
budget.spentFormatted,
budget.limitFormatted,
),
remaining = if (budget.isOver) {
stringResource(Res.string.budget_over_format, budget.remainderFormatted)
} else {
stringResource(Res.string.budget_remaining_format, budget.remainderFormatted)
},
progress = budget.progress,
footer = stringResource(
Res.string.budget_footer_format,
budgetPeriodLabel(budget.period),
pluralStringResource(Res.plurals.budget_days_left, budget.daysLeft, budget.daysLeft),
),
alertFraction = budget.alertFraction,
),
categories = budget.categories.map { category ->
val visual = SurferCategoryPalette.visualFor(
id = category.id,
iconKey = category.iconKey,
hue = category.hue,
systemKind = category.systemKind,
)
SurferBudgetCategoryVisual(icon = visual.icon, tint = visual.tint)
},
onClick = onClick,
)
}
}
@Preview
@Composable
private fun BudgetsContentPreview() {
AppTheme {
BudgetsContent(
state = BudgetsState.Content(
active = listOf(previewBudget()),
archived = emptyList(),
),
onEvent = {},
)
}
}