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 = {},
         )
     }
 }