Coverage Summary for Class: TransactionCreationScreenKt (com.georgeci.moneysurfer.feature.transaction.creation)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| TransactionCreationScreenKt |
52.2%
(12/23)
|
22.5%
(48/213)
|
65.9%
(137/208)
|
54.5%
(1700/3121)
|
| TransactionCreationScreenKt$SyncAmountFields$1$1 |
100%
(1/1)
|
|
100%
(2/2)
|
100%
(22/22)
|
| TransactionCreationScreenKt$SyncAmountFields$1$1$2 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(9/9)
|
| TransactionCreationScreenKt$SyncAmountFields$2$1 |
100%
(1/1)
|
100%
(2/2)
|
100%
(2/2)
|
100%
(25/25)
|
| TransactionCreationScreenKt$SyncAmountFields$3$1 |
100%
(1/1)
|
|
100%
(2/2)
|
100%
(22/22)
|
| TransactionCreationScreenKt$SyncAmountFields$3$1$2 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(9/9)
|
| TransactionCreationScreenKt$SyncAmountFields$4$1 |
100%
(1/1)
|
50%
(1/2)
|
50%
(1/2)
|
36%
(9/25)
|
| TransactionCreationScreenKt$TransactionCreationScreen$3$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/16)
|
| TransactionCreationScreenKt$TransactionCreationScreen$4$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/16)
|
| TransactionCreationScreenKt$TransactionCreationScreen$5$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/15)
|
| TransactionCreationScreenKt$WhenMappings |
|
| Total |
56.2%
(18/32)
|
22.9%
(51/223)
|
65.2%
(146/224)
|
54.8%
(1796/3280)
|
package com.georgeci.moneysurfer.feature.transaction.creation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.primitives.AccountId
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.domain.primitives.TransactionId
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.components.base.SurferSegmentedControl
import com.georgeci.moneysurfer.uikit.components.transaction.SurferDeleteTransactionDialog
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.transaction.generated.resources.Res
import moneysurfer.feature.transaction.generated.resources.transaction_creation_account_empty
import moneysurfer.feature.transaction.generated.resources.transaction_creation_account_label
import moneysurfer.feature.transaction.generated.resources.transaction_creation_close_content_description
import moneysurfer.feature.transaction.generated.resources.transaction_creation_expense
import moneysurfer.feature.transaction.generated.resources.transaction_creation_income
import moneysurfer.feature.transaction.generated.resources.transaction_creation_note_label
import moneysurfer.feature.transaction.generated.resources.transaction_creation_title_create
import moneysurfer.feature.transaction.generated.resources.transaction_creation_today
import moneysurfer.feature.transaction.generated.resources.transaction_creation_transfer
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/** Stable selectors for the transaction creation screen — see docs/testing/testing-strategy.md. */
object TransactionCreationTestTags {
const val Root = "transactionCreation:root"
const val Amount = "transactionCreation:amount"
const val Note = "transactionCreation:note"
const val Save = "transactionCreation:save"
const val Close = "transactionCreation:close"
const val Delete = "transactionCreation:delete"
/**
* Transaction-type option, suffixed with the lowercased [TransactionTypeUi]. `transfer` is
* only composed outside edit mode and with the transfer flag on.
*/
const val TypePrefix = "transactionCreation:type:"
}
private fun transactionCreationSeed(
transactionId: TransactionId?,
duplicate: Boolean,
): TransactionCreationSeed? = transactionId?.let {
TransactionCreationSeed(
transactionId = it,
mode = if (duplicate) {
TransactionCreationSeed.Mode.Duplicate
} else {
TransactionCreationSeed.Mode.Edit
},
)
}
@Composable
fun TransactionCreationScreen(
transactionId: TransactionId? = null,
accountId: AccountId? = null,
/** Treat [transactionId] as a template for a new transaction rather than the row to edit. */
duplicate: Boolean = false,
/**
* Open already switched to Transfer, for a caller that knows that is what the user asked for.
* Applied once, when the ViewModel loads — not re-applied on later compositions, so the user
* stays wherever they moved the type segment to afterwards.
*/
transfer: Boolean = false,
onNavigateBack: () -> Unit,
/**
* Leaving after the edited transaction was deleted. Defaults to [onNavigateBack]; the nav graph
* overrides it to also drop the details screen of the row that no longer exists.
*/
onNavigateBackAfterDelete: () -> Unit = onNavigateBack,
onNavigateToCategoryChooser: (CategoryId?, CategoryType) -> Unit,
onNavigateToCategoryCreation: () -> Unit,
onNavigateToAccountChooser: (AccountId?, AccountId?, Boolean) -> Unit,
pickedCategoryId: CategoryId? = null,
pickedAccountId: AccountId? = null,
transferRequested: Boolean? = null,
viewModel: TransactionCreationViewModel = koinViewModel(
key = "$transactionId:$accountId:$duplicate:$transfer",
) { parametersOf(transactionCreationSeed(transactionId, duplicate), accountId, transfer) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
TransactionCreationEffect.NavigateBack -> onNavigateBack()
TransactionCreationEffect.NavigateBackAfterDelete -> onNavigateBackAfterDelete()
is TransactionCreationEffect.NavigateToCategoryChooser -> onNavigateToCategoryChooser(
effect.selectedCategoryId,
effect.filterType,
)
TransactionCreationEffect.NavigateToCategoryCreation -> onNavigateToCategoryCreation()
is TransactionCreationEffect.NavigateToAccountChooser -> onNavigateToAccountChooser(
effect.selectedAccountId,
effect.excludeAccountId,
effect.showTransferShortcut,
)
}
}
LaunchedEffect(pickedCategoryId) {
val id = pickedCategoryId ?: return@LaunchedEffect
viewModel.onEvent(TransactionCreationEvent.OnCategoryPicked(id))
}
LaunchedEffect(pickedAccountId) {
val id = pickedAccountId ?: return@LaunchedEffect
viewModel.onEvent(TransactionCreationEvent.OnAccountPicked(id))
}
// [transfer] is deliberately absent here — it is a route argument, true for as long as this
// entry lives, so replaying it on every composition would drag the user back to the Transfer
// tab after they had switched away. It seeds the ViewModel's initial state instead.
LaunchedEffect(transferRequested) {
if (transferRequested != true) return@LaunchedEffect
viewModel.onEvent(TransactionCreationEvent.OnTypeChanged(TransactionTypeUi.Transfer))
}
when (val current = state) {
TransactionCreationState.Loading -> TransactionCreationLoading(onEvent = viewModel::onEvent)
is TransactionCreationState.Content -> TransactionCreationContent(
state = current,
onEvent = viewModel::onEvent,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TransactionCreationLoading(onEvent: (TransactionCreationEvent) -> Unit) {
Scaffold(
modifier = Modifier.surferSafeInsets(),
containerColor = AppTheme.materialColors.surface,
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.transaction_creation_title_create)) },
navigationIcon = {
Box(
modifier = Modifier
.padding(start = 4.dp)
.size(48.dp)
.clickable { onEvent(TransactionCreationEvent.OnBackClick) },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = SurferIcons.Close,
contentDescription = stringResource(
Res.string.transaction_creation_close_content_description,
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = AppTheme.materialColors.surface,
),
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding))
}
}
/**
* The form, drawn from a state it is handed rather than one it resolves.
*
* Public for the desktop UI tests in `:composeApp` `jvmTest`, which mount it with a state of their
* own — see docs/testing/testing-strategy.md.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionCreationContent(
state: TransactionCreationState.Content,
onEvent: (TransactionCreationEvent) -> Unit,
amountState: TextFieldState = rememberTextFieldState(state.amount),
toAmountState: TextFieldState = rememberTextFieldState(state.toAmount),
) {
SyncAmountFields(state = state, amountState = amountState, toAmountState = toAmountState, onEvent = onEvent)
if (state.showDeleteConfirmation) {
SurferDeleteTransactionDialog(
titleOrNull = state.note.ifBlank { null },
onConfirm = { onEvent(TransactionCreationEvent.OnDeleteConfirmed) },
onDismiss = { onEvent(TransactionCreationEvent.OnDeleteDismissed) },
isTransfer = state.isTransfer,
isSplit = state.isEditingSplitLeg,
)
}
Scaffold(
modifier = Modifier
.surferSafeInsets()
.surferTestTagAsId()
.testTag(TransactionCreationTestTags.Root),
containerColor = AppTheme.materialColors.surface,
topBar = { CreationTopBar(state = state, onEvent = onEvent) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding())
.verticalScroll(rememberScrollState()),
) {
state.editIdentity?.let { identity ->
EditIdentityBand(
identity = identity,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = AppTheme.spacing.default),
)
}
TypeSegments(state = state, onEvent = onEvent)
Spacer(Modifier.height(AppTheme.spacing.large))
if (state.isTransfer) {
TransferAccountsBlock(
state = state,
fromAmountState = amountState,
toAmountState = toAmountState,
onEvent = onEvent,
)
} else {
AmountHero(
type = state.type,
currencySymbol = currencySymbol(state.selectedAccount?.currencyCode),
amountState = amountState,
error = state.amountError,
)
}
Spacer(Modifier.height(AppTheme.spacing.default))
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(bottom = padding.calculateBottomPadding() + AppTheme.spacing.large),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
if (!state.isTransfer) {
SurferPickerRow(
label = stringResource(Res.string.transaction_creation_account_label),
value = state.selectedAccount?.name
?: stringResource(Res.string.transaction_creation_account_empty),
icon = SurferIcons.CreditCard,
onClick = { onEvent(TransactionCreationEvent.OnOpenAccountChooser) },
)
// The grid picks the transaction's single category, so it has nothing to say
// once the receipt is being split — each line picks its own instead.
if (state.isSplit) {
SplitLinesBlock(state = state, onEvent = onEvent)
} else {
CategoryGridSection(
categories = state.displayCategories,
selected = state.selectedCategory,
onSelect = { onEvent(TransactionCreationEvent.OnCategorySelected(it)) },
onAllClick = { onEvent(TransactionCreationEvent.OnOpenCategoryChooser) },
onMoreClick = { onEvent(TransactionCreationEvent.OnOpenCategoryCreation) },
)
}
SplitToggleRow(state = state, onEvent = onEvent)
}
OutlinedTextField(
value = state.note,
onValueChange = { onEvent(TransactionCreationEvent.OnNoteChanged(it)) },
label = { Text(stringResource(Res.string.transaction_creation_note_label)) },
shape = AppTheme.shapes.extraSmall,
modifier = Modifier
.fillMaxWidth()
.testTag(TransactionCreationTestTags.Note),
minLines = 2,
maxLines = 4,
)
DateRow(state = state, onEvent = onEvent)
}
}
}
}
/** Keeps the two text-field buffers and the ViewModel's amounts in step, in both directions. */
@Composable
private fun SyncAmountFields(
state: TransactionCreationState.Content,
amountState: TextFieldState,
toAmountState: TextFieldState,
onEvent: (TransactionCreationEvent) -> Unit,
) {
LaunchedEffect(amountState) {
snapshotFlow { amountState.text.toString() }
.collect { onEvent(TransactionCreationEvent.OnAmountChanged(it)) }
}
LaunchedEffect(state.amount) {
if (amountState.text.toString() != state.amount) {
amountState.edit { replace(0, length, state.amount) }
}
}
LaunchedEffect(toAmountState) {
snapshotFlow { toAmountState.text.toString() }
.collect { onEvent(TransactionCreationEvent.OnToAmountChanged(it)) }
}
LaunchedEffect(state.toAmount) {
if (toAmountState.text.toString() != state.toAmount) {
toAmountState.edit { replace(0, length, state.toAmount) }
}
}
}
@Composable
private fun TypeSegments(
state: TransactionCreationState.Content,
onEvent: (TransactionCreationEvent) -> Unit,
) {
// Editing an existing single-leg transaction can't morph into a paired transfer in place —
// hide the Transfer segment to avoid silently creating a new transfer pair alongside the
// original row. The offline build also hides Transfer entirely (multi-account transfers are
// out of MVP scope) via `transferEnabled`.
val transferVisible = !state.isEditMode && state.transferEnabled
val typeOptions = remember(transferVisible) {
if (transferVisible) {
listOf(TransactionTypeUi.Expense, TransactionTypeUi.Income, TransactionTypeUi.Transfer)
} else {
listOf(TransactionTypeUi.Expense, TransactionTypeUi.Income)
}
}
val expenseLabel = stringResource(Res.string.transaction_creation_expense)
val incomeLabel = stringResource(Res.string.transaction_creation_income)
val transferLabel = stringResource(Res.string.transaction_creation_transfer)
SurferSegmentedControl(
options = typeOptions,
selected = state.type,
label = {
when (it) {
TransactionTypeUi.Expense -> expenseLabel
TransactionTypeUi.Income -> incomeLabel
TransactionTypeUi.Transfer -> transferLabel
}
},
onSelect = { onEvent(TransactionCreationEvent.OnTypeChanged(it)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
optionTestTag = { type ->
TransactionCreationTestTags.TypePrefix + type.name.lowercase()
},
)
}
@Composable
private fun DateRow(
state: TransactionCreationState.Content,
onEvent: (TransactionCreationEvent) -> Unit,
) {
var showDatePicker by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DateTimeField(
timestamp = state.timestamp,
onClick = { showDatePicker = true },
modifier = Modifier.weight(1f),
)
SurferButton(
text = stringResource(Res.string.transaction_creation_today),
onClick = { onEvent(TransactionCreationEvent.OnTodayClick) },
style = SurferButtonStyle.Tonal,
size = SurferButtonSize.Biggest,
startIcon = SurferIcons.Event,
)
}
if (showDatePicker) {
TransactionDatePickerDialog(
initialTimestamp = state.timestamp,
onDismiss = { showDatePicker = false },
onConfirm = { picked ->
onEvent(TransactionCreationEvent.OnDateChanged(picked))
showDatePicker = false
},
)
}
}