Coverage Summary for Class: AccountCreationScreenKt (com.georgeci.moneysurfer.feature.account.creation)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| AccountCreationScreenKt |
56.9%
(29/51)
|
34.3%
(59/172)
|
74.1%
(197/266)
|
65.4%
(2456/3758)
|
| AccountCreationScreenKt$AccountCreationContent$2$1$4$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/9)
|
| AccountCreationScreenKt$WhenMappings |
|
| Total |
55.8%
(29/52)
|
34.3%
(59/172)
|
73.8%
(197/267)
|
65.2%
(2456/3767)
|
package com.georgeci.moneysurfer.feature.account.creation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.model.AccountExtraDetailKey
import com.georgeci.moneysurfer.domain.model.AccountExtraDetails
import com.georgeci.moneysurfer.domain.model.Currency
import com.georgeci.moneysurfer.domain.primitives.AccountType
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.feature.account.extraDetailLabel
import com.georgeci.moneysurfer.feature.account.generated.resources.Res
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_add_another_label
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_balance_error_negative
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_balance_helper
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_balance_label
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_cancel
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_currency_label
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_currency_search_placeholder
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_custom_field_add
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_custom_field_dialog_title
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_custom_field_name_label
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_extra_details_label
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_extra_details_optional
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_field_custom
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_first_run_subtitle
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_first_run_title
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_name_error_required
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_name_label
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_remove
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_remove_content_description
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_save
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_title
import com.georgeci.moneysurfer.feature.account.generated.resources.account_creation_type_label
import com.georgeci.moneysurfer.feature.account.isMonospaceExtraDetail
import com.georgeci.moneysurfer.feature.account.labelRes
import com.georgeci.moneysurfer.uikit.components.SurferCurrencyBottomSheet
import com.georgeci.moneysurfer.uikit.components.SurferCurrencyOption
import com.georgeci.moneysurfer.uikit.components.SurferCurrencyPickerField
import com.georgeci.moneysurfer.uikit.components.SurferTextField
import com.georgeci.moneysurfer.uikit.components.base.SurferFormSection
import com.georgeci.moneysurfer.uikit.components.base.SurferOutlinedChip
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.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 org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
/** Stable selectors for the account creation screen — see docs/testing/testing-strategy.md. */
object AccountCreationTestTags {
const val Root = "accountCreation:root"
const val Name = "accountCreation:name"
const val OpeningBalance = "accountCreation:openingBalance"
const val Iban = "accountCreation:iban"
const val Save = "accountCreation:save"
/**
* Chip that *adds* an extra-detail field, suffixed with the lowercased
* [AccountExtraDetailKey]. Distinct from the field it creates ([Iban]): both carry the same
* label, so a text selector cannot tell "add IBAN" from "type into IBAN".
*/
const val AddExtraDetailPrefix = "accountCreation:addExtra:"
/** Account-type option, suffixed with the lowercased [AccountType]. */
const val TypePrefix = "accountCreation:type:"
}
@Composable
fun AccountCreationScreen(
onNavigateBack: () -> Unit,
onNavigateToDashboard: () -> Unit = {},
accountId: com.georgeci.moneysurfer.domain.primitives.AccountId? = null,
firstRun: Boolean = false,
initialType: AccountType = AccountType.SAVINGS,
viewModel: AccountCreationViewModel = koinViewModel(
key = accountId?.value.orEmpty(),
) { org.koin.core.parameter.parametersOf(accountId, firstRun, initialType) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
AccountCreationEffect.NavigateBack -> onNavigateBack()
AccountCreationEffect.NavigateToDashboard -> onNavigateToDashboard()
}
}
when (val current = state) {
is AccountCreationState.Loading -> AccountCreationLoading(
firstRun = firstRun,
onEvent = viewModel::onEvent,
)
is AccountCreationState.Content -> AccountCreationContent(
state = current,
firstRun = firstRun,
onEvent = viewModel::onEvent,
)
}
}
@Composable
private fun AccountCreationLoading(
firstRun: Boolean,
onEvent: (AccountCreationEvent) -> Unit,
) {
Scaffold(
modifier = Modifier.surferSafeInsets(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(accountCreationTitle(firstRun)),
// No way back out of the first-launch step — there is nowhere to go yet.
onBack = if (firstRun) null else ({ onEvent(AccountCreationEvent.OnBackClick) }),
)
},
) { padding ->
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize().padding(padding))
}
}
/**
* The form, drawn from a state it is handed rather than one it resolves.
*
* `internal` for the Roborazzi captures in `androidHostTest`, which mount it with a state of their
* own — see docs/testing/screenshot-tests.md.
*/
@Composable
internal fun AccountCreationContent(
state: AccountCreationState.Content,
firstRun: Boolean,
onEvent: (AccountCreationEvent) -> Unit,
) {
Scaffold(
modifier = Modifier
.surferSafeInsets()
.testTag(AccountCreationTestTags.Root)
.surferTestTagAsId(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = stringResource(accountCreationTitle(firstRun)),
onBack = if (firstRun) null else ({ onEvent(AccountCreationEvent.OnBackClick) }),
actions = {
SurferToolbarButtonAction(
icon = SurferIcons.Check,
text = stringResource(Res.string.account_creation_save),
onClick = { onEvent(AccountCreationEvent.OnSaveClick) },
enabled = state.canSave,
modifier = Modifier.testTag(AccountCreationTestTags.Save),
)
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding())
.verticalScroll(rememberScrollState())
.padding(horizontal = AppTheme.spacing.large)
.padding(top = AppTheme.spacing.small),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.large),
) {
if (firstRun) {
Text(
text = stringResource(Res.string.account_creation_first_run_subtitle),
style = AppTheme.typography.bodyMedium,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
TypePicker(
selected = state.type,
onSelect = { onEvent(AccountCreationEvent.OnTypeChanged(it)) },
)
SurferTextField(
value = state.name,
onValueChange = { onEvent(AccountCreationEvent.OnNameChanged(it)) },
label = stringResource(Res.string.account_creation_name_label),
errorText = stringResource(Res.string.account_creation_name_error_required)
.takeIf { state.nameMissing },
fieldTestTag = AccountCreationTestTags.Name,
)
if (!state.isEditMode) {
if (state.currencies.isNotEmpty()) {
CurrencyPicker(
currencies = state.currencies,
selected = state.currency,
onSelect = { onEvent(AccountCreationEvent.OnCurrencyChanged(it)) },
)
}
val balanceError = state.balanceError
SurferTextField(
value = state.balance,
onValueChange = { onEvent(AccountCreationEvent.OnBalanceChanged(it)) },
label = stringResource(Res.string.account_creation_balance_label),
errorText = balanceError?.let { stringResource(it.messageRes()) },
helperText = stringResource(Res.string.account_creation_balance_helper),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text(state.currencySymbol) },
fieldTestTag = AccountCreationTestTags.OpeningBalance,
)
}
if (state.extraDetailsEnabled) {
ExtraDetailsSection(
addedFields = state.extraFields,
availableKeys = state.availableExtraFieldKeys,
canAddField = state.canAddExtraField,
onValueChanged = { key, value ->
onEvent(AccountCreationEvent.OnExtraFieldValueChanged(key, value))
},
onAdd = { onEvent(AccountCreationEvent.OnAddExtraField(it)) },
onAddCustom = { onEvent(AccountCreationEvent.OnAddCustomExtraField(it)) },
onRemove = { onEvent(AccountCreationEvent.OnRemoveExtraField(it)) },
)
}
Spacer(Modifier.height(padding.calculateBottomPadding() + AppTheme.spacing.large))
}
}
}
@Composable
private fun TypePicker(
selected: AccountType,
onSelect: (AccountType) -> Unit,
) {
SurferFormSection(label = stringResource(Res.string.account_creation_type_label)) {
SurferSegmentedControl(
options = listOf(AccountType.CASH, AccountType.BANK, AccountType.CARD, AccountType.SAVINGS),
selected = selected,
label = { type -> stringResource(type.labelRes()) },
onSelect = onSelect,
optionTestTag = { type -> AccountCreationTestTags.TypePrefix + type.name.lowercase() },
)
}
}
/**
* A field that opens the currency bottom sheet. The supported list is already eight entries and
* still growing, which a segmented control cannot hold on a phone (see issue #277).
*/
@Composable
private fun CurrencyPicker(
currencies: List<Currency>,
selected: CurrencyCode,
onSelect: (CurrencyCode) -> Unit,
) {
val resolved = currencies.firstOrNull { it.code == selected } ?: currencies.first()
var sheetOpen by rememberSaveable { mutableStateOf(false) }
SurferFormSection(label = stringResource(Res.string.account_creation_currency_label)) {
SurferCurrencyPickerField(
symbol = resolved.symbol,
code = resolved.code.value,
name = resolved.displayName,
onClick = { sheetOpen = true },
expanded = sheetOpen,
)
}
if (sheetOpen) {
SurferCurrencyBottomSheet(
title = stringResource(Res.string.account_creation_currency_label),
searchPlaceholder = stringResource(Res.string.account_creation_currency_search_placeholder),
currencies = currencies.map {
SurferCurrencyOption(code = it.code.value, symbol = it.symbol, name = it.displayName)
},
selectedCode = resolved.code.value,
onSelect = {
onSelect(CurrencyCode(it.code))
sheetOpen = false
},
onDismiss = { sheetOpen = false },
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ExtraDetailsSection(
addedFields: List<AccountExtraField>,
availableKeys: List<AccountExtraDetailKey>,
canAddField: Boolean,
onValueChanged: (String, String) -> Unit,
onAdd: (AccountExtraDetailKey) -> Unit,
onAddCustom: (String) -> Unit,
onRemove: (String) -> Unit,
) {
var namingCustomField by remember { mutableStateOf(false) }
if (namingCustomField) {
CustomFieldNameDialog(
onDismiss = { namingCustomField = false },
onConfirm = { name ->
namingCustomField = false
onAddCustom(name)
},
)
}
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = AppTheme.spacing.medium),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.account_creation_extra_details_label),
style = AppTheme.typography.labelLarge,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(Res.string.account_creation_extra_details_optional),
style = AppTheme.typography.labelSmall,
color = AppTheme.materialColors.onSurfaceVariant,
)
}
Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.default)) {
addedFields.forEach { field ->
ExtraFieldItem(
field = field,
onValueChanged = { onValueChanged(field.key, it) },
onRemove = { onRemove(field.key) },
)
}
}
if (canAddField) {
Spacer(Modifier.height(14.dp))
Text(
text = stringResource(Res.string.account_creation_add_another_label),
style = AppTheme.typography.labelMedium,
color = AppTheme.materialColors.onSurfaceVariant,
modifier = Modifier.padding(bottom = AppTheme.spacing.small),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
) {
availableKeys.forEach { key ->
SurferOutlinedChip(
label = stringResource(key.labelRes()),
icon = SurferIcons.Add,
onClick = { onAdd(key) },
modifier = Modifier.testTag(
AccountCreationTestTags.AddExtraDetailPrefix + key.name.lowercase(),
),
)
}
SurferOutlinedChip(
label = stringResource(Res.string.account_creation_field_custom),
icon = SurferIcons.Add,
onClick = { namingCustomField = true },
)
}
}
}
}
/**
* Names a "Custom field…" before it is added. Confirming with a blank name is blocked here; a
* name that duplicates a field already on the form is dropped by the ViewModel, which is the only
* place that knows the current rows.
*/
@Composable
private fun CustomFieldNameDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.account_creation_custom_field_dialog_title)) },
text = {
OutlinedTextField(
value = name,
onValueChange = { if (it.length <= AccountExtraDetails.MAX_KEY_LENGTH) name = it },
label = { Text(stringResource(Res.string.account_creation_custom_field_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(name) },
enabled = name.isNotBlank(),
) {
Text(stringResource(Res.string.account_creation_custom_field_add))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.account_creation_cancel))
}
},
containerColor = AppTheme.materialColors.surface,
)
}
@Composable
private fun ExtraFieldItem(
field: AccountExtraField,
onValueChanged: (String) -> Unit,
onRemove: () -> Unit,
) {
val label = extraDetailLabel(field.key)
// Only the well-known IBAN row gets a stable selector: every other row is either a different
// well-known key or a user-named custom field, neither of which the E2E flows target.
val fieldTag = if (field.wellKnownKey == AccountExtraDetailKey.IBAN) {
AccountCreationTestTags.Iban
} else {
null
}
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(
onClick = onRemove,
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = AppTheme.spacing.small,
vertical = 0.dp,
),
) {
Icon(
imageVector = SurferIcons.Close,
contentDescription = stringResource(
Res.string.account_creation_remove_content_description,
label,
),
tint = AppTheme.materialColors.primary,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(Res.string.account_creation_remove),
style = AppTheme.typography.labelMedium,
color = AppTheme.materialColors.primary,
)
}
}
OutlinedTextField(
value = field.value,
onValueChange = onValueChanged,
label = { Text(label) },
modifier = Modifier
.fillMaxWidth()
.then(fieldTag?.let { Modifier.testTag(it) } ?: Modifier),
singleLine = !field.isMultiline,
minLines = if (field.isMultiline) 3 else 1,
maxLines = if (field.isMultiline) 5 else 1,
textStyle = if (isMonospaceExtraDetail(field.key)) {
AppTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace)
} else {
AppTheme.typography.bodyLarge
},
)
}
}
private val AccountExtraField.isMultiline: Boolean
get() = wellKnownKey == AccountExtraDetailKey.DESCRIPTION
private fun InitialBalanceError.messageRes(): StringResource = when (this) {
InitialBalanceError.NEGATIVE_NOT_ALLOWED -> Res.string.account_creation_balance_error_negative
}
/** First launch gets a welcoming title; every other entry keeps the plain screen title. */
private fun accountCreationTitle(firstRun: Boolean): StringResource =
if (firstRun) Res.string.account_creation_first_run_title else Res.string.account_creation_title
@Preview
@Composable
private fun AccountCreationScreenPreview() {
AppTheme {
AccountCreationContent(
state = AccountCreationState.Content(
name = "Emergency fund",
balance = "2,480.00",
type = AccountType.BANK,
currency = CurrencyCode("EUR"),
currencies = listOf(
Currency(CurrencyCode("EUR"), symbol = "€", displayName = "Euro"),
Currency(CurrencyCode("USD"), symbol = "$", displayName = "US Dollar"),
Currency(CurrencyCode("GBP"), symbol = "£", displayName = "British Pound"),
Currency(CurrencyCode("PLN"), symbol = "zł", displayName = "Polish Zloty"),
),
extraFields = listOf(
AccountExtraField(
key = AccountExtraDetailKey.IBAN.name,
value = "PL61 1090 1014 0000 0712 1981 2874",
),
AccountExtraField(
key = AccountExtraDetailKey.DESCRIPTION.name,
value = "Joint emergency reserve — 6 months of expenses.",
),
AccountExtraField(key = "Broker code", value = "MS-4417"),
),
editingAccountId = null,
),
firstRun = false,
onEvent = {},
)
}
}