Coverage Summary for Class: TransactionDetailsEffect (com.georgeci.moneysurfer.feature.transaction.details)

Class Method, % Branch, % Line, % Instruction, %
TransactionDetailsEffect$NavigateBack 100% (1/1) 100% (1/1) 100% (2/2)
TransactionDetailsEffect$NavigateToDuplicate 100% (1/1) 100% (1/1) 100% (7/7)
TransactionDetailsEffect$NavigateToEdit 100% (1/1) 100% (1/1) 100% (7/7)
Total 100% (3/3) 100% (3/3) 100% (16/16)


 package com.georgeci.moneysurfer.feature.transaction.details
 
 import arrow.optics.optics
 import com.georgeci.moneysurfer.domain.formatter.MoneyFormatter
 import com.georgeci.moneysurfer.domain.model.Account
 import com.georgeci.moneysurfer.domain.model.Category
 import com.georgeci.moneysurfer.domain.model.CategoryAppearance
 import com.georgeci.moneysurfer.domain.model.Transaction
 import com.georgeci.moneysurfer.domain.model.reference
 import com.georgeci.moneysurfer.domain.primitives.Money
 import com.georgeci.moneysurfer.domain.primitives.TransactionId
 import com.georgeci.moneysurfer.domain.primitives.TransactionStatus
 import com.georgeci.moneysurfer.domain.primitives.TransactionType
 import com.georgeci.moneysurfer.domain.usecase.GetAccountByIdUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCategoriesUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetSplitLegsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetTransactionByIdUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetTransferCounterpartUseCase
 import com.georgeci.moneysurfer.navigation.DeleteTransactionWithUndo
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.flow.first
 import moneysurfer.feature.transaction.generated.resources.Res
 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 resolves the transaction plus everything the three screen variants need around it.
 @KoinViewModel
 @Suppress("LongParameterList")
 class TransactionDetailsViewModel(
     transactionId: TransactionId,
     private val getTransactionById: GetTransactionByIdUseCase,
     private val getAccountById: GetAccountByIdUseCase,
     private val getCategories: GetCategoriesUseCase,
     private val getTransferCounterpart: GetTransferCounterpartUseCase,
     private val getSplitLegs: GetSplitLegsUseCase,
     private val deleteWithUndo: DeleteTransactionWithUndo,
 ) : MviViewModel<TransactionDetailsState, TransactionDetailsEvent, TransactionDetailsEffect>(
     initialState = TransactionDetailsState.Loading(transactionId),
 ) {
 
     init {
         loadData()
     }
 
     override fun onEvent(event: TransactionDetailsEvent) {
         when (event) {
             TransactionDetailsEvent.OnBackClick ->
                 postSideEffect(TransactionDetailsEffect.NavigateBack)
             TransactionDetailsEvent.OnEditClick ->
                 postSideEffect(TransactionDetailsEffect.NavigateToEdit(currentState.transactionId))
             TransactionDetailsEvent.OnDuplicateClick ->
                 postSideEffect(TransactionDetailsEffect.NavigateToDuplicate(currentState.transactionId))
             TransactionDetailsEvent.OnDeleteClick ->
                 updateState {
                     TransactionDetailsState.content.showDeleteConfirmation.modify(this) { true }
                 }
             TransactionDetailsEvent.OnDeleteConfirmed -> handleDelete()
             TransactionDetailsEvent.OnDeleteDismissed ->
                 updateState {
                     TransactionDetailsState.content.showDeleteConfirmation.modify(this) { false }
                 }
         }
     }
 
     private fun loadData() {
         launch {
             val transactionId = currentState.transactionId
             val transaction = getTransactionById(transactionId) ?: return@launch
             val account = getAccountById(transaction.accountId)
             val categories = getCategories().first()
             val category = categories.find { it.id == transaction.categoryId }
             val parentCategory = category?.parentId?.let { parentId -> categories.find { it.id == parentId } }
             val transfer = resolveTransferLeg(transaction, account)
             val split = resolveSplit(transaction, categories)
 
             updateState {
                 TransactionDetailsState.Content(
                     transactionId = transactionId,
                     formattedAmount = formatAmount(transaction, isTransfer = transfer != null),
                     type = transaction.type,
                     transfer = transfer,
                     split = split,
                     note = transaction.note,
                     merchant = transaction.merchant,
                     accountName = account?.name.orEmpty(),
                     categoryName = category?.name.orEmpty(),
                     parentCategoryName = parentCategory?.name,
                     categorySystemKind = category?.systemKind?.name,
                     categoryId = category?.id?.value.orEmpty(),
                     categoryIconKey = category?.iconKey.orEmpty(),
                     categoryHue = category?.hue ?: CategoryAppearance.UNSET_HUE,
                     tags = transaction.tags,
                     reference = transactionId.reference,
                     formattedDate = formatDate(transaction.operationDate),
                     isPlanned = transaction.status == TransactionStatus.PLANNED,
                     showDeleteConfirmation = false,
                 )
             }
         }
     }
 
     /**
      * Null unless [transaction] is one leg of a transfer. Neither leg stores the other account,
      * so the sibling has to be resolved through the shared `transferId` — and the pair may be
      * missing, in which case the screen still renders as a transfer with one side blank rather
      * than silently degrading to an expense.
      */
     private suspend fun resolveTransferLeg(transaction: Transaction, account: Account?): TransferLeg? {
         if (transaction.transferId == null) return null
         val counterpartName = getTransferCounterpart(transaction)
             ?.let { getAccountById(it.accountId) }
             ?.name
             .orEmpty()
         val ownName = account?.name.orEmpty()
         return if (transaction.type == TransactionType.INCOME) {
             TransferLeg(fromAccountName = counterpartName, toAccountName = ownName)
         } else {
             TransferLeg(fromAccountName = ownName, toAccountName = counterpartName)
         }
     }
 
     /**
      * The receipt [transaction] is one leg of, or null when it is an ordinary single-category row.
      *
      * A group of one is treated as no split at all: a leg can reach this device from sync or a CSV
      * import before (or without) its siblings, and there is nothing to break down until they land.
      */
     private suspend fun resolveSplit(
         transaction: Transaction,
         categories: List<Category>,
     ): SplitBreakdown? {
         val legs = getSplitLegs(transaction)
         if (legs.size < MIN_SPLIT_LEGS) return null
         val total = legs.fold(Money.zero()) { acc, leg -> acc + leg.money.abs() }
         return SplitBreakdown(
             formattedTotal = MoneyFormatter.format(total, transaction.currencyCode),
             legs = legs.map { leg ->
                 SplitLegUi(
                     transactionId = leg.id,
                     categoryName = categories.find { it.id == leg.categoryId }?.name.orEmpty(),
                     formattedAmount = MoneyFormatter.format(leg.money.abs(), leg.currencyCode),
                     isCurrent = leg.id == transaction.id,
                 )
             },
         )
     }
 
     /**
      * Transfers stay unsigned — money moved sideways, it neither arrived nor left. Only the
      * income/expense variants carry a sign, matching the transaction list.
      */
     private fun formatAmount(transaction: Transaction, isTransfer: Boolean): String {
         val formatted = MoneyFormatter.format(transaction.money.abs(), transaction.currencyCode)
         if (isTransfer) return formatted
         return when (transaction.type) {
             TransactionType.INCOME -> "+$formatted"
             TransactionType.EXPENSE -> "−$formatted"
             TransactionType.OPENING_BALANCE -> formatted
         }
     }
 
     private fun formatDate(date: kotlinx.datetime.LocalDate): String {
         val month = date.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)
         val day = date.day.toString().padStart(2, '0')
         return "$day $month ${date.year}"
     }
 
     private fun handleDelete() {
         launch {
             deleteWithUndo(
                 transactionId = currentState.transactionId,
                 message = Res.string.transaction_details_deleted_snackbar,
                 undoLabel = Res.string.transaction_details_delete_undo,
             )
             postSideEffect(TransactionDetailsEffect.NavigateBack)
         }
     }
 
     private companion object {
         /** Below this a group is not a split — see [resolveSplit]. */
         const val MIN_SPLIT_LEGS = 2
     }
 }
 
 /**
  * The legs one receipt was split across, as the details screen draws them under the amount.
  *
  * Present only when the group really has several legs; [SplitLegUi.isCurrent] marks the one the
  * screen was opened on, so the reader can see which slice of the receipt they navigated to.
  */
 data class SplitBreakdown(
     val formattedTotal: String,
     val legs: List<SplitLegUi>,
 )
 
 /** One leg of a split: where it was filed, how much of the receipt it took. */
 data class SplitLegUi(
     val transactionId: TransactionId,
     /** Blank when the leg is uncategorized — the screen substitutes its own label. */
     val categoryName: String,
     val formattedAmount: String,
     val isCurrent: Boolean,
 )
 
 /**
  * The two accounts a transfer moves money between, already oriented for display: [fromAccountName]
  * is always the source regardless of which leg is on screen. Either name is blank when the account
  * (or the paired leg) could not be resolved.
  */
 data class TransferLeg(
     val fromAccountName: String,
     val toAccountName: String,
 )
 
 @optics
 sealed interface TransactionDetailsState {
     val transactionId: TransactionId
 
     @optics
     data class Loading(override val transactionId: TransactionId) : TransactionDetailsState {
         companion object
     }
 
     @optics
     data class Content(
         override val transactionId: TransactionId,
         val formattedAmount: String,
         /**
          * The stored type. A transfer is not a type of its own — both its legs are an ordinary
          * `EXPENSE`/`INCOME` pair — so [transfer] is what tells the transfer variant apart.
          */
         val type: TransactionType,
         /** Non-null when this transaction is one leg of a transfer. */
         val transfer: TransferLeg? = null,
         /** Non-null when this transaction is one leg of a receipt split across categories. */
         val split: SplitBreakdown? = null,
         val note: String,
         val merchant: String = "",
         val accountName: String,
         val categoryName: String,
         /** Parent of [categoryName] when the category is nested, else null. */
         val parentCategoryName: String? = null,
         val categorySystemKind: String? = null,
         /** Stored appearance of the transaction's category, fed to the shared bubble resolver. */
         val categoryId: String = "",
         val categoryIconKey: String = "",
         val categoryHue: Int = CategoryAppearance.UNSET_HUE,
         val tags: List<String> = emptyList(),
         /** Derived short label (`TX-8A13`), see [com.georgeci.moneysurfer.domain.model.reference]. */
         val reference: String = "",
         val formattedDate: String,
         val isPlanned: Boolean,
         val showDeleteConfirmation: Boolean,
     ) : TransactionDetailsState {
 
         val isTransfer: Boolean get() = transfer != null
 
         val isIncome: Boolean get() = !isTransfer && type == TransactionType.INCOME
 
         val isSplit: Boolean get() = split != null
 
         /**
          * Duplicating one leg of a transfer would produce a half-transfer, duplicating one leg of a
          * split would produce a fragment of a receipt with no group to belong to, and an opening
          * balance is an account artefact rather than something a user re-enters — none of the three
          * offers the action.
          */
         val canDuplicate: Boolean
             get() = !isTransfer && !isSplit && type != TransactionType.OPENING_BALANCE
 
         companion object
     }
 
     companion object
 }
 
 sealed interface TransactionDetailsEvent {
     data object OnBackClick : TransactionDetailsEvent
     data object OnEditClick : TransactionDetailsEvent
     data object OnDuplicateClick : TransactionDetailsEvent
     data object OnDeleteClick : TransactionDetailsEvent
     data object OnDeleteConfirmed : TransactionDetailsEvent
     data object OnDeleteDismissed : TransactionDetailsEvent
 }
 
 sealed interface TransactionDetailsEffect {
     data object NavigateBack : TransactionDetailsEffect
     data class NavigateToEdit(val transactionId: TransactionId) : TransactionDetailsEffect
 
     /** Open the creation screen prefilled from [transactionId], as a brand-new transaction. */
     data class NavigateToDuplicate(val transactionId: TransactionId) : TransactionDetailsEffect
 }