Coverage Summary for Class: BudgetEditScreenKt (com.georgeci.moneysurfer.feature.budget.edit)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| BudgetEditScreenKt |
0%
(0/1)
|
0%
(0/5)
|
0%
(0/34)
|
0%
(0/25)
|
0%
(0/474)
|
package com.georgeci.moneysurfer.feature.budget.edit
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
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.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.BudgetPeriod
import com.georgeci.moneysurfer.domain.primitives.BudgetId
import com.georgeci.moneysurfer.feature.budget.budgetPeriodLabel
import com.georgeci.moneysurfer.feature.budget.formatShortDate
import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
import com.georgeci.moneysurfer.uikit.components.base.SurferSectionLabel
import com.georgeci.moneysurfer.uikit.components.base.SurferSegmentedControl
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbarButtonAction
import com.georgeci.moneysurfer.uikit.components.budget.SurferCategoryChip
import com.georgeci.moneysurfer.uikit.components.budget.SurferCategoryChipGrid
import com.georgeci.moneysurfer.uikit.components.budget.SurferPercentStepper
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.AmountInputTransformation
import com.georgeci.moneysurfer.utils.HandleSideEffect
import moneysurfer.feature.budget.generated.resources.Res
import moneysurfer.feature.budget.generated.resources.budget_edit_alert_decrease
import moneysurfer.feature.budget.generated.resources.budget_edit_alert_increase
import moneysurfer.feature.budget.generated.resources.budget_edit_alert_label
import moneysurfer.feature.budget.generated.resources.budget_edit_amount_label
import moneysurfer.feature.budget.generated.resources.budget_edit_amount_required
import moneysurfer.feature.budget.generated.resources.budget_edit_categories_hint
import moneysurfer.feature.budget.generated.resources.budget_edit_categories_label
import moneysurfer.feature.budget.generated.resources.budget_edit_name_label
import moneysurfer.feature.budget.generated.resources.budget_edit_name_required
import moneysurfer.feature.budget.generated.resources.budget_edit_period_label
import moneysurfer.feature.budget.generated.resources.budget_edit_rollover_label
import moneysurfer.feature.budget.generated.resources.budget_edit_rollover_supporting
import moneysurfer.feature.budget.generated.resources.budget_edit_save
import moneysurfer.feature.budget.generated.resources.budget_edit_start_date_label
import moneysurfer.feature.budget.generated.resources.budget_edit_title_create
import moneysurfer.feature.budget.generated.resources.budget_edit_title_edit
import moneysurfer.feature.budget.generated.resources.budgets_all_categories
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/** Stable selectors for the budget create/edit screen — see docs/testing/testing-strategy.md. */
object BudgetEditTestTags {
const val Root = "budgetEdit:root"
const val NameField = "budgetEdit:name"
const val AmountField = "budgetEdit:amount"
const val SaveButton = "budgetEdit:save"
}
@Composable
fun BudgetEditScreen(
budgetId: BudgetId? = null,
onNavigateBack: () -> Unit,
viewModel: BudgetEditViewModel = koinViewModel(
key = budgetId?.value ?: "new",
) { parametersOf(budgetId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
BudgetEditEffect.NavigateBack -> onNavigateBack()
}
}
BudgetEditContent(
state = state,
isEditing = budgetId != null,
onEvent = viewModel::onEvent,
)
}
@Composable
private fun BudgetEditContent(
state: BudgetEditState,
isEditing: Boolean,
onEvent: (BudgetEditEvent) -> Unit,
) {
Scaffold(
modifier = Modifier
.surferSafeInsets()
.testTag(BudgetEditTestTags.Root)
.surferTestTagAsId(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(
if (isEditing) Res.string.budget_edit_title_edit else Res.string.budget_edit_title_create,
),
onBack = { onEvent(BudgetEditEvent.OnBackClick) },
actions = {
SurferToolbarButtonAction(
icon = SurferIcons.Check,
text = stringResource(Res.string.budget_edit_save),
onClick = { onEvent(BudgetEditEvent.OnSaveClick) },
enabled = state.canSave,
modifier = Modifier
.testTag(BudgetEditTestTags.SaveButton),
)
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = AppTheme.spacing.default, vertical = AppTheme.spacing.medium),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.default),
) {
OutlinedTextField(
value = state.name,
onValueChange = { onEvent(BudgetEditEvent.OnNameChanged(it)) },
label = { Text(stringResource(Res.string.budget_edit_name_label)) },
isError = state.nameMissing,
supportingText = if (state.nameMissing) {
{ Text(stringResource(Res.string.budget_edit_name_required)) }
} else {
null
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(BudgetEditTestTags.NameField),
)
OutlinedTextField(
value = state.amount,
onValueChange = { input ->
AmountInputTransformation.validateAndNormalize(input)?.let {
onEvent(BudgetEditEvent.OnAmountChanged(it))
}
},
label = { Text(stringResource(Res.string.budget_edit_amount_label)) },
isError = state.amountMissing,
supportingText = if (state.amountMissing) {
{ Text(stringResource(Res.string.budget_edit_amount_required)) }
} else {
null
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(BudgetEditTestTags.AmountField),
)
SurferSectionLabel(stringResource(Res.string.budget_edit_period_label))
SurferSegmentedControl(
options = BudgetPeriod.entries,
selected = state.period,
label = { budgetPeriodLabel(it) },
onSelect = { onEvent(BudgetEditEvent.OnPeriodChanged(it)) },
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.budget_edit_start_date_label),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = formatShortDate(state.startDate),
style = AppTheme.typography.bodyLarge,
color = AppTheme.materialColors.onSurface,
)
}
SurferSectionLabel(stringResource(Res.string.budget_edit_categories_label))
SurferCategoryChipGrid(
categories = state.categoryOptions.map { category ->
val visual = SurferCategoryPalette.visualFor(
id = category.id,
iconKey = category.iconKey,
hue = category.hue,
systemKind = category.systemKind,
)
SurferCategoryChip(
id = category.id,
name = category.name,
icon = visual.icon,
tint = visual.tint,
)
},
selectedIds = state.selectedCategoryIds,
allCategoriesLabel = stringResource(Res.string.budgets_all_categories),
onToggle = { onEvent(BudgetEditEvent.OnCategoryToggled(it)) },
onSelectAllCategories = { onEvent(BudgetEditEvent.OnAllCategoriesSelected) },
)
Text(
text = stringResource(Res.string.budget_edit_categories_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.budget_edit_alert_label),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
SurferPercentStepper(
value = state.alertPercent,
onValueChange = { onEvent(BudgetEditEvent.OnAlertPercentChanged(it)) },
decreaseContentDescription = stringResource(Res.string.budget_edit_alert_decrease),
increaseContentDescription = stringResource(Res.string.budget_edit_alert_increase),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(Res.string.budget_edit_rollover_label),
style = AppTheme.typography.bodyLarge,
color = AppTheme.materialColors.onSurface,
)
Text(
text = stringResource(Res.string.budget_edit_rollover_supporting),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
Switch(
checked = state.rollover,
onCheckedChange = { onEvent(BudgetEditEvent.OnRolloverChanged(it)) },
)
}
}
}
}
@Preview
@Composable
private fun BudgetEditContentPreview() {
AppTheme {
BudgetEditContent(
state = BudgetEditState(name = "Groceries", amount = "400"),
isEditing = false,
onEvent = {},
)
}
}