Coverage Summary for Class: TransactionCreationSplitBlockKt (com.georgeci.moneysurfer.feature.transaction.creation)

Class Class, % Method, % Branch, % Line, % Instruction, %
TransactionCreationSplitBlockKt 100% (1/1) 83.3% (5/6) 62.1% (41/66) 95.5% (85/89) 94.2% (1065/1130)


 package com.georgeci.moneysurfer.feature.transaction.creation
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 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.unit.dp
 import com.georgeci.moneysurfer.domain.formatter.MoneyFormatter
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.uikit.components.SurferButton
 import com.georgeci.moneysurfer.uikit.components.SurferButtonSize
 import com.georgeci.moneysurfer.uikit.components.SurferButtonStyle
 import com.georgeci.moneysurfer.uikit.components.SurferPickerRow
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import moneysurfer.feature.transaction.generated.resources.Res
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_amount_label
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_category_label
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_add_line
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_category_placeholder
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_enable
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_over
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_remainder
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_remove_line
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_title
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_total
 import org.jetbrains.compose.resources.stringResource
 
 /** Stable selectors for the split editor — see docs/testing/testing-strategy.md. */
 object TransactionSplitTestTags {
     const val Toggle = "transactionCreation:split:toggle"
     const val AddLine = "transactionCreation:split:addLine"
 
     /** One line's amount field, suffixed with the line's index. */
     const val LineAmountPrefix = "transactionCreation:split:amount:"
 }
 
 /**
  * Turns the single-category form into a receipt filed under several categories, and back.
  *
  * Offered on creation only. Converting a stored transaction into a group would mean deleting the
  * row and writing N in its place — new ids, a different thing to sync — which is a larger change
  * than an edit screen should make behind one toggle.
  */
 @Composable
 internal fun SplitToggleRow(
     state: TransactionCreationState.Content,
     onEvent: (TransactionCreationEvent) -> Unit,
 ) {
     if (state.isEditMode) return
     Row(
         modifier = Modifier.fillMaxWidth(),
         horizontalArrangement = Arrangement.End,
     ) {
         SurferButton(
             text = stringResource(Res.string.transaction_creation_split_enable),
             onClick = { onEvent(TransactionCreationEvent.OnSplitToggled) },
             style = if (state.isSplit) SurferButtonStyle.Tonal else SurferButtonStyle.Text,
             // Deliberately the smallest size: this row sits between the category grid and the note
             // field on a scrolling form, and every dp it adds pushes the note (and the Save button
             // the flows tap) further under the keyboard on a short screen.
             size = SurferButtonSize.Small,
             startIcon = SurferIcons.Category,
             modifier = Modifier.testTag(TransactionSplitTestTags.Toggle),
         )
     }
 }
 
 /**
  * The lines a split will be written as: a category and a slice of the amount each.
  *
  * The **last** line is not editable — it always carries whatever is left of the amount above. That
  * is what makes the legs add up by construction: there is no way to save a receipt whose parts do
  * not sum to the payment, and no arithmetic for the user to redo when they change one line.
  */
 @Composable
 internal fun SplitLinesBlock(
     state: TransactionCreationState.Content,
     onEvent: (TransactionCreationEvent) -> Unit,
 ) {
     val currency = state.selectedAccount?.currencyCode ?: CurrencyCode("USD")
     Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.small)) {
         Text(
             text = stringResource(Res.string.transaction_creation_split_title),
             style = AppTheme.typography.titleSmall,
             color = AppTheme.materialColors.onSurface,
         )
         state.splitLines.forEachIndexed { index, line ->
             SplitLineRow(
                 line = line,
                 index = index,
                 // Only the trailing line is derived; every line above it is the user's to type.
                 remainderFormatted = if (index == state.splitLines.lastIndex) {
                     MoneyFormatter.format(state.splitRemainder, currency)
                 } else {
                     null
                 },
                 onEvent = onEvent,
             )
         }
         SurferButton(
             text = stringResource(Res.string.transaction_creation_split_add_line),
             onClick = { onEvent(TransactionCreationEvent.OnSplitLineAdded) },
             style = SurferButtonStyle.Text,
             size = SurferButtonSize.Biggest,
             startIcon = SurferIcons.Add,
             modifier = Modifier.testTag(TransactionSplitTestTags.AddLine),
         )
         SplitStatusLine(state = state, currency = currency)
     }
 }
 
 @Composable
 private fun SplitLineRow(
     line: TransactionSplitLineUi,
     index: Int,
     remainderFormatted: String?,
     onEvent: (TransactionCreationEvent) -> Unit,
 ) {
     Row(
         modifier = Modifier.fillMaxWidth(),
         horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         SurferPickerRow(
             label = stringResource(Res.string.transaction_creation_category_label),
             value = line.category?.name
                 ?: stringResource(Res.string.transaction_creation_split_category_placeholder),
             icon = SurferIcons.Category,
             onClick = { onEvent(TransactionCreationEvent.OnOpenSplitLineCategoryChooser(line.key)) },
             modifier = Modifier.weight(1f),
         )
         OutlinedTextField(
             value = remainderFormatted ?: line.amount,
             onValueChange = { typed ->
                 onEvent(TransactionCreationEvent.OnSplitLineAmountChanged(line.key, typed))
             },
             label = { Text(stringResource(Res.string.transaction_creation_amount_label)) },
             singleLine = true,
             readOnly = remainderFormatted != null,
             keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
             shape = AppTheme.shapes.extraSmall,
             modifier = Modifier
                 .width(SPLIT_AMOUNT_FIELD_WIDTH)
                 .testTag(TransactionSplitTestTags.LineAmountPrefix + index),
         )
         IconButton(onClick = { onEvent(TransactionCreationEvent.OnSplitLineRemoved(line.key)) }) {
             Icon(
                 imageVector = SurferIcons.Close,
                 contentDescription = stringResource(Res.string.transaction_creation_split_remove_line),
                 tint = AppTheme.materialColors.onSurfaceVariant,
             )
         }
     }
 }
 
 /**
  * How the lines stand against the amount. Over-assigning is the only state the editor calls out in
  * error colour — a positive remainder is simply a line the user has not filled in yet.
  */
 @Composable
 private fun SplitStatusLine(state: TransactionCreationState.Content, currency: CurrencyCode) {
     val remainder = state.splitRemainder
     val overAssigned = remainder.isNegative()
     val text = when {
         overAssigned -> stringResource(
             Res.string.transaction_creation_split_over,
             MoneyFormatter.format(remainder.abs(), currency),
         )
         remainder.isZero() -> stringResource(
             Res.string.transaction_creation_split_total,
             MoneyFormatter.format(state.splitTotal, currency),
             MoneyFormatter.format(state.splitTotal, currency),
         )
         else -> stringResource(
             Res.string.transaction_creation_split_remainder,
             MoneyFormatter.format(remainder, currency),
         )
     }
     Text(
         text = text,
         style = AppTheme.typography.bodySmall,
         color = if (overAssigned) {
             AppTheme.materialColors.error
         } else {
             AppTheme.materialColors.onSurfaceVariant
         },
     )
 }
 
 /** Wide enough for a five-figure amount without stealing the category picker's room. */
 private val SPLIT_AMOUNT_FIELD_WIDTH = 132.dp