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

Class Method, % Branch, % Line, % Instruction, %
TransactionCreationViewModel 100% (24/24) 86.9% (185/213) 100% (166/166) 96.6% (1294/1340)
TransactionCreationViewModel$applyPickedAccount$2 100% (2/2) 80% (8/10) 100% (6/6) 97.1% (99/102)
TransactionCreationViewModel$applyPickedCategory$2 100% (2/2) 80% (8/10) 100% (8/8) 96% (72/75)
TransactionCreationViewModel$handleDelete$1 100% (1/1) 100% (5/5) 100% (24/24)
TransactionCreationViewModel$loadData$1 100% (2/2) 78.6% (22/28) 100% (33/33) 97.8% (264/270)
TransactionCreationViewModel$observeCategoryUsage$1 100% (1/1) 100% (1/1) 100% (19/19)
TransactionCreationViewModel$observeCategoryUsage$1$1 100% (2/2) 100% (2/2) 100% (12/12) 100% (58/58)
TransactionCreationViewModel$observeCategoryUsage$1$1$emit$$inlined$groupingBy$1 0% (0/3)
TransactionCreationViewModel$saveSplit$1 100% (1/1) 75% (3/4) 100% (13/13) 100% (79/79)
TransactionCreationViewModel$saveTransaction$1 100% (1/1) 100% (8/8) 100% (29/29) 100% (181/181)
TransactionCreationViewModel$saveTransfer$1 100% (1/1) 50% (1/2) 100% (14/14) 100% (82/82)
TransactionCreationViewModel$TransferPlan 100% (1/1) 100% (5/5) 100% (22/22)
TransactionCreationViewModel$WhenMappings
Total 92.7% (38/41) 85.6% (237/277) 100% (292/292) 97.4% (2194/2252)


 package com.georgeci.moneysurfer.feature.transaction.creation
 
 import com.georgeci.moneysurfer.domain.config.HostCapabilities
 import com.georgeci.moneysurfer.domain.model.Account
 import com.georgeci.moneysurfer.domain.model.Transaction
 import com.georgeci.moneysurfer.domain.primitives.AccountId
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.Money
 import com.georgeci.moneysurfer.domain.primitives.TransactionId
 import com.georgeci.moneysurfer.domain.primitives.TransactionType
 import com.georgeci.moneysurfer.domain.repositories.TransactionRepository
 import com.georgeci.moneysurfer.domain.usecase.CreateSplitTransactionUseCase
 import com.georgeci.moneysurfer.domain.usecase.CreateTransactionUseCase
 import com.georgeci.moneysurfer.domain.usecase.CreateTransferUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetAccountsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCategoriesUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCurrentTimeUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetTransactionByIdUseCase
 import com.georgeci.moneysurfer.domain.usecase.UpdateTransactionUseCase
 import com.georgeci.moneysurfer.navigation.DeleteTransactionWithUndo
 import com.georgeci.moneysurfer.navigation.SnackbarController
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.flow.first
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
 import moneysurfer.feature.transaction.generated.resources.Res
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_created_snackbar
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_split_snackbar
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_transfer_snackbar
 import moneysurfer.feature.transaction.generated.resources.transaction_creation_updated_snackbar
 import moneysurfer.feature.transaction.generated.resources.transaction_details_delete_undo
 import moneysurfer.feature.transaction.generated.resources.transaction_details_deleted_snackbar
 import org.koin.core.annotation.KoinViewModel
 
 // ViewModel composes loading + creation + transfer flows; splitting now would push wiring to a holder.
 @KoinViewModel
 @Suppress("LongParameterList")
 class TransactionCreationViewModel(
     private val seed: TransactionCreationSeed?,
     private val accountId: AccountId?,
     /**
      * Open on the Transfer tab, for a caller that already knows that is what the user asked for.
      * Read once while loading rather than applied as an event from the screen: a route argument is
      * true for the whole lifetime of the entry, so replaying it on every composition would undo a
      * type the user has since changed by hand (after a rotation, say).
      */
     private val openAsTransfer: Boolean,
     private val getAccounts: GetAccountsUseCase,
     private val getCategories: GetCategoriesUseCase,
     private val getTransactionById: GetTransactionByIdUseCase,
     private val createTransaction: CreateTransactionUseCase,
     private val updateTransaction: UpdateTransactionUseCase,
     private val createTransfer: CreateTransferUseCase,
     private val createSplitTransaction: CreateSplitTransactionUseCase,
     private val deleteWithUndo: DeleteTransactionWithUndo,
     private val getCurrentTime: GetCurrentTimeUseCase,
     private val transactionRepository: TransactionRepository,
     private val hostCapabilities: HostCapabilities,
     private val snackbar: SnackbarController,
 ) : MviViewModel<TransactionCreationState, TransactionCreationEvent, TransactionCreationEffect>(
     initialState = TransactionCreationState.Loading,
 ) {
 
     init {
         loadData()
         observeCategoryUsage()
     }
 
     private var pendingAccountSlot: AccountSlot = AccountSlot.Single
 
     /** What the category chooser was last opened for — see [CategorySlot]. */
     private var pendingCategorySlot: CategorySlot = CategorySlot.Single
 
     /** Hands out the split lines' stable keys; never reused within one screen. */
     private var splitLineKeys: Int = 0
 
     override fun onEvent(event: TransactionCreationEvent) {
         when (event) {
             is TransactionCreationEvent.OnAmountChanged ->
                 updateState { TransactionCreationState.content.amount.modify(this) { event.amount } }
             is TransactionCreationEvent.OnToAmountChanged ->
                 updateState { TransactionCreationState.content.toAmount.modify(this) { event.amount } }
             is TransactionCreationEvent.OnNoteChanged ->
                 updateState { TransactionCreationState.content.note.modify(this) { event.note } }
             is TransactionCreationEvent.OnAccountSelected ->
                 updateState { TransactionCreationState.content.selectedAccount.modify(this) { event.account } }
             is TransactionCreationEvent.OnCategorySelected ->
                 updateState { TransactionCreationState.content.selectedCategory.modify(this) { event.category } }
             is TransactionCreationEvent.OnCategoryPicked -> applyPickedCategory(event.id)
             is TransactionCreationEvent.OnAccountPicked -> applyPickedAccount(event.id)
             is TransactionCreationEvent.OnTypeChanged -> changeType(event.type)
             is TransactionCreationEvent.OnDateChanged -> updateState {
                 TransactionCreationState.content.timestamp.set(this, event.timestamp)
                     .let { TransactionCreationState.content.pinnedOperationDate.set(it, null) }
             }
             TransactionCreationEvent.OnTodayClick -> updateState {
                 TransactionCreationState.content.timestamp
                     .set(this, getCurrentTime().toEpochMilliseconds())
                     .let { TransactionCreationState.content.pinnedOperationDate.set(it, null) }
             }
             TransactionCreationEvent.OnOpenCategoryChooser -> openCategoryChooser(CategorySlot.Single)
             TransactionCreationEvent.OnOpenCategoryCreation -> postSideEffect(
                 TransactionCreationEffect.NavigateToCategoryCreation,
             )
             TransactionCreationEvent.OnOpenAccountChooser -> openAccountChooser(AccountSlot.Single)
             TransactionCreationEvent.OnOpenFromAccountChooser -> openAccountChooser(AccountSlot.From)
             TransactionCreationEvent.OnOpenToAccountChooser -> openAccountChooser(AccountSlot.To)
             TransactionCreationEvent.OnSwapAccountsClick -> updateContent { content ->
                 content.copy(
                     fromAccount = content.toAccount,
                     toAccount = content.fromAccount,
                     amount = content.toAmount,
                     toAmount = content.amount,
                 )
             }
             TransactionCreationEvent.OnSplitToggled ->
                 updateContent { it.withSplitToggled { splitLineKeys++ } }
             TransactionCreationEvent.OnSplitLineAdded ->
                 updateContent { it.withSplitLineAdded(key = splitLineKeys++) }
             is TransactionCreationEvent.OnSplitLineRemoved ->
                 updateContent { it.withSplitLineRemoved(key = event.key) }
             is TransactionCreationEvent.OnSplitLineAmountChanged ->
                 updateContent { it.withSplitLineAmount(key = event.key, amount = event.amount) }
             is TransactionCreationEvent.OnOpenSplitLineCategoryChooser ->
                 openCategoryChooser(CategorySlot.SplitLine(event.key))
             TransactionCreationEvent.OnSaveClick -> saveTransaction()
             TransactionCreationEvent.OnDeleteClick -> updateState {
                 TransactionCreationState.content.showDeleteConfirmation.modify(this) { true }
             }
             TransactionCreationEvent.OnDeleteDismissed -> updateState {
                 TransactionCreationState.content.showDeleteConfirmation.modify(this) { false }
             }
             TransactionCreationEvent.OnDeleteConfirmed -> handleDelete()
             TransactionCreationEvent.OnBackClick -> postSideEffect(TransactionCreationEffect.NavigateBack)
         }
     }
 
     /**
      * Reduces the loaded form, leaving [TransactionCreationState.Loading] alone — an event that
      * arrives before the screen has loaded has no form to change.
      *
      * A `when` over the sealed state rather than the `as?` this used to repeat at every call site:
      * one place to state the rule, and no cast for a static analyser to squint at (SonarCloud reads
      * `this` inside a `STATE.() -> STATE` reducer as the ViewModel and calls the cast impossible).
      */
     private fun updateContent(
         block: (TransactionCreationState.Content) -> TransactionCreationState.Content,
     ) = updateState {
         when (this) {
             is TransactionCreationState.Content -> block(this)
             TransactionCreationState.Loading -> this
         }
     }
 
     private fun openCategoryChooser(slot: CategorySlot) {
         val state = currentState as? TransactionCreationState.Content ?: return
         pendingCategorySlot = slot
         val selected = when (slot) {
             CategorySlot.Single -> state.selectedCategory?.id
             is CategorySlot.SplitLine ->
                 state.splitLines.firstOrNull { it.key == slot.key }?.category?.id
         }
         postSideEffect(
             TransactionCreationEffect.NavigateToCategoryChooser(
                 selectedCategoryId = selected,
                 filterType = state.type.categoryType(),
             ),
         )
     }
 
     private fun openAccountChooser(slot: AccountSlot) {
         val state = currentState as? TransactionCreationState.Content ?: return
         pendingAccountSlot = slot
         val (selected, excluded) = when (slot) {
             AccountSlot.Single -> state.selectedAccount?.id to null
             AccountSlot.From -> state.fromAccount?.id to state.toAccount?.id
             AccountSlot.To -> state.toAccount?.id to state.fromAccount?.id
         }
         postSideEffect(
             TransactionCreationEffect.NavigateToAccountChooser(
                 selectedAccountId = selected,
                 excludeAccountId = excluded,
                 // Only a single-account pick can still become a transfer; the From/To slots are
                 // already inside one. Same gate as the type switch, so the footer never leads
                 // somewhere [changeType] would refuse to go.
                 showTransferShortcut = slot == AccountSlot.Single &&
                     !state.isEditMode &&
                     state.transferEnabled,
             ),
         )
     }
 
     private fun changeType(nextType: TransactionTypeUi) = updateState {
         val content = this as? TransactionCreationState.Content ?: return@updateState this
         // Editing an existing single-leg transaction can't morph into a paired transfer in place;
         // ignore the switch so we don't desync the type with the row that's about to be updated.
         // `content.transferEnabled` rather than a fresh `hostCapabilities` read: a QA override landing
         // mid-screen would otherwise leave a rendered Transfer segment that this guard refuses.
         if (nextType == TransactionTypeUi.Transfer && (content.isEditMode || !content.transferEnabled)) {
             return@updateState content
         }
         val nextCategoryType = nextType.categoryType()
         val nextSelected = pickDefaultCategory(content.categories, content.categoryUsageCounts, nextCategoryType)
         val seededFrom = content.fromAccount ?: content.selectedAccount
         val seededTo = content.toAccount
             ?: content.accounts.firstOrNull { it.id != seededFrom?.id }
         content.copy(
             type = nextType,
             selectedCategory = nextSelected,
             displayCategories = buildDisplayCategories(
                 categories = content.categories,
                 counts = content.categoryUsageCounts,
                 type = nextCategoryType,
                 selected = nextSelected,
             ),
             fromAccount = if (nextType == TransactionTypeUi.Transfer) seededFrom else content.fromAccount,
             toAccount = if (nextType == TransactionTypeUi.Transfer) seededTo else content.toAccount,
             // The split editor's lines carry categories of the *old* type, and every category
             // belongs to one side. Keeping them would write income legs filed under expense
             // categories — money the spend aggregates (which filter on `type = 'EXPENSE'`) would
             // never see, under a category whose own screen would then list income. The lines and
             // their amounts survive; only a category the new type cannot use is dropped, which
             // leaves Save disabled until the user picks a replacement.
             splitLines = content.splitLines.map { line ->
                 line.copy(category = line.category?.takeIf { it.type == nextCategoryType })
             },
         )
     }
 
     private fun loadData() {
         launch {
             val accounts = getAccounts().first()
             val categories = getCategories().first()
             val prefillAccount = accountId?.let { id -> accounts.find { it.id == id } }
 
             // Same three gates the type switch applies: a seeded screen is an edit or a duplicate
             // of a single-leg row, and a build with transfers off has no Transfer tab to open on.
             val openingTransfer = openAsTransfer && seed == null && hostCapabilities.transferEnabled
             val initialType = if (openingTransfer) TransactionTypeUi.Transfer else TransactionTypeUi.Expense
             val initialCategoryType = initialType.categoryType()
             val initialSelected = pickDefaultCategory(categories, emptyMap(), initialCategoryType)
             val initialAccount = prefillAccount ?: accounts.firstOrNull()
 
             val baseContent = TransactionCreationState.Content(
                 amount = "",
                 note = "",
                 type = initialType,
                 accounts = accounts,
                 categories = categories,
                 selectedAccount = initialAccount,
                 // Seeded the way [changeType] seeds them, so arriving on the Transfer tab by route
                 // and reaching it by tapping the segment leave the same two slots filled.
                 fromAccount = if (openingTransfer) initialAccount else null,
                 toAccount = if (openingTransfer) accounts.firstOrNull { it.id != initialAccount?.id } else null,
                 selectedCategory = initialSelected,
                 isEditMode = false,
                 editingTransactionId = null,
                 timestamp = getCurrentTime().toEpochMilliseconds(),
                 categoryUsageCounts = emptyMap(),
                 displayCategories = buildDisplayCategories(
                     categories = categories,
                     counts = emptyMap(),
                     type = initialCategoryType,
                     selected = initialSelected,
                 ),
                 transferEnabled = hostCapabilities.transferEnabled,
             )
 
             val existing = seed?.let { getTransactionById(it.transactionId) }
             updateState {
                 if (seed == null || existing == null) {
                     baseContent
                 } else {
                     baseContent.seededFrom(seed, existing)
                 }
             }
         }
     }
 
     private fun observeCategoryUsage() {
         launch {
             transactionRepository.getAll().collect { all ->
                 val counts = all
                     .mapNotNull { it.categoryId }
                     .groupingBy { it }
                     .eachCount()
                 updateContent { content ->
                     content.copy(
                         categoryUsageCounts = counts,
                         displayCategories = buildDisplayCategories(
                             categories = content.categories,
                             counts = counts,
                             type = content.type.categoryType(),
                             selected = content.selectedCategory,
                         ),
                     )
                 }
             }
         }
     }
 
     private fun applyPickedAccount(picked: AccountId) {
         val content = currentState as? TransactionCreationState.Content ?: return
         val slot = pendingAccountSlot
         val match = content.accounts.find { it.id == picked }
         if (match != null) {
             updateState {
                 val c = this as? TransactionCreationState.Content ?: return@updateState this
                 c.withAccountInSlot(match, slot)
             }
             return
         }
         launch {
             val refreshed = getAccounts().first()
             val newMatch = refreshed.find { it.id == picked }
             updateState {
                 val c = this as? TransactionCreationState.Content ?: return@updateState this
                 val withRefreshed = c.copy(accounts = refreshed)
                 if (newMatch != null) withRefreshed.withAccountInSlot(newMatch, slot) else withRefreshed
             }
         }
     }
 
     private fun applyPickedCategory(picked: CategoryId) {
         val content = currentState as? TransactionCreationState.Content ?: return
         val slot = pendingCategorySlot
         val match = content.categories.find { it.id == picked }
         if (match != null) {
             updateState {
                 val c = this as? TransactionCreationState.Content ?: return@updateState this
                 c.withPickedCategory(categories = c.categories, selected = match, slot = slot)
             }
             return
         }
         // Categories may not be loaded yet (e.g. just-created entry); refresh once.
         launch {
             val refreshed = getCategories().first()
             val newMatch = refreshed.find { it.id == picked }
             updateState {
                 val c = this as? TransactionCreationState.Content ?: return@updateState this
                 c.withPickedCategory(
                     categories = refreshed,
                     selected = newMatch ?: c.categoryInSlot(slot),
                     slot = slot,
                 )
             }
         }
     }
 
     private fun saveTransaction() {
         val state = currentState as? TransactionCreationState.Content ?: return
         if (state.isTransfer) {
             saveTransfer(state)
             return
         }
         if (state.isSplit) {
             saveSplit(state)
             return
         }
         val account = state.selectedAccount ?: return
         val category = state.selectedCategory ?: return
         val amountDouble = TransactionAmountInput.parse(state.amount)?.takeIf { it > 0 } ?: return
 
         val money = Money.fromDouble(amountDouble).abs()
         val type = if (state.isExpense) TransactionType.EXPENSE else TransactionType.INCOME
 
         launch {
             val now = getCurrentTime()
             val zone = TimeZone.currentSystemDefault()
             val operationAt = kotlin.time.Instant.fromEpochMilliseconds(state.timestamp)
             val transaction = Transaction(
                 id = state.editingTransactionId ?: TransactionId.uuid(),
                 workspaceId = account.workspaceId,
                 accountId = account.id,
                 money = money,
                 currencyCode = account.currencyCode,
                 categoryId = category.id,
                 note = state.note,
                 // This rebuilds the whole row rather than patching the stored one, so every field
                 // the form does not edit has to be handed back explicitly — omitting one resets it.
                 merchant = state.preserved.merchant,
                 tags = state.preserved.tags,
                 operationAt = operationAt,
                 operationDate = state.pinnedOperationDate
                     ?: operationAt.toLocalDateTime(zone).date,
                 type = type,
                 status = state.preserved.status,
                 createdAt = state.editingCreatedAt ?: now,
                 updatedAt = now,
                 transferId = state.preserved.transferId,
                 splitId = state.preserved.splitId,
                 recurringRuleId = state.preserved.recurringRuleId,
             )
 
             if (state.isEditMode) {
                 updateTransaction(transaction)
                 snackbar.show(Res.string.transaction_creation_updated_snackbar)
             } else {
                 createTransaction(transaction)
                 snackbar.show(Res.string.transaction_creation_created_snackbar)
             }
 
             postSideEffect(TransactionCreationEffect.NavigateBack)
         }
     }
 
     /**
      * Writes the split editor's lines as one receipt: N sibling rows sharing a split id, each with
      * its own category and slice of the amount.
      *
      * Guarded twice on purpose — the Save button is disabled unless [isSplitComplete][
      * TransactionCreationState.Content.isSplitComplete] holds, and the use case rejects a group it
      * could not write. The state's own check is what keeps a half-filled editor from reaching a
      * `require` and crashing the screen.
      */
     private fun saveSplit(state: TransactionCreationState.Content) {
         val account = state.selectedAccount ?: return
         if (!state.isSplitComplete) return
         val legs = state.splitLegs()
 
         launch {
             val zone = TimeZone.currentSystemDefault()
             val operationAt = kotlin.time.Instant.fromEpochMilliseconds(state.timestamp)
             createSplitTransaction(
                 CreateSplitTransactionUseCase.Params(
                     account = account,
                     legs = legs,
                     note = state.note,
                     operationAt = operationAt,
                     operationDate = state.pinnedOperationDate
                         ?: operationAt.toLocalDateTime(zone).date,
                     type = if (state.isExpense) TransactionType.EXPENSE else TransactionType.INCOME,
                 ),
             )
             snackbar.show(Res.string.transaction_creation_split_snackbar)
             postSideEffect(TransactionCreationEffect.NavigateBack)
         }
     }
 
     private fun saveTransfer(state: TransactionCreationState.Content) {
         val plan = buildTransferPlan(state) ?: return
         launch {
             val zone = TimeZone.currentSystemDefault()
             val operationAt = kotlin.time.Instant.fromEpochMilliseconds(state.timestamp)
             createTransfer(
                 CreateTransferUseCase.Params(
                     from = plan.from,
                     to = plan.to,
                     fromMoney = Money.fromDouble(plan.fromAmount).abs(),
                     toMoney = Money.fromDouble(plan.toAmount).abs(),
                     note = state.note,
                     operationAt = operationAt,
                     operationDate = state.pinnedOperationDate
                         ?: operationAt.toLocalDateTime(zone).date,
                 ),
             )
             snackbar.show(Res.string.transaction_creation_transfer_snackbar)
             postSideEffect(TransactionCreationEffect.NavigateBack)
         }
     }
 
     /**
      * Deletes the row being edited, exactly the way the details screen and the lists do — the same
      * [DeleteTransactionWithUndo], so the undo snackbar and what a delete takes with it are one
      * behaviour rather than a per-screen approximation of one.
      *
      * Guarded on [TransactionCreationState.Content.editingTransactionId]: there is nothing to
      * delete while creating or duplicating, and the screen hides the action there.
      */
     private fun handleDelete() {
         val editingId = (currentState as? TransactionCreationState.Content)?.editingTransactionId ?: return
         launch {
             deleteWithUndo(
                 transactionId = editingId,
                 message = Res.string.transaction_details_deleted_snackbar,
                 undoLabel = Res.string.transaction_details_delete_undo,
             )
             postSideEffect(TransactionCreationEffect.NavigateBackAfterDelete)
         }
     }
 
     private data class TransferPlan(
         val from: Account,
         val to: Account,
         val fromAmount: Double,
         val toAmount: Double,
     )
 
     @Suppress("ReturnCount") // Sequential null/range guards stay clearer than a single nested expression.
     private fun buildTransferPlan(state: TransactionCreationState.Content): TransferPlan? {
         val from = state.fromAccount ?: return null
         val to = state.toAccount ?: return null
         if (from.id == to.id) return null
         val fromAmount = TransactionAmountInput.parse(state.amount)?.takeIf { it > 0 } ?: return null
         val toAmount = if (state.crossCurrency) {
             TransactionAmountInput.parse(state.toAmount)?.takeIf { it > 0 } ?: return null
         } else {
             fromAmount
         }
         return TransferPlan(from, to, fromAmount, toAmount)
     }
 }