Coverage Summary for Class: CategoryDetailsEvent (com.georgeci.moneysurfer.feature.category.details)

Class Method, % Branch, % Line, % Instruction, %
CategoryDetailsEvent$OnAddTransactionClick 100% (1/1) 100% (1/1) 100% (2/2)
CategoryDetailsEvent$OnBackClick 100% (1/1) 100% (1/1) 100% (2/2)
CategoryDetailsEvent$OnDeleteTransaction 100% (1/1) 100% (1/1) 100% (7/7)
CategoryDetailsEvent$OnEditClick 100% (1/1) 100% (1/1) 100% (2/2)
CategoryDetailsEvent$OnSubcategoryClick 100% (1/1) 100% (1/1) 100% (7/7)
CategoryDetailsEvent$OnTransactionClick 100% (1/1) 100% (1/1) 100% (7/7)
Total 100% (6/6) 100% (6/6) 100% (27/27)


 package com.georgeci.moneysurfer.feature.category.details
 
 import arrow.optics.optics
 import com.georgeci.moneysurfer.domain.formatter.MoneyFormatter
 import com.georgeci.moneysurfer.domain.model.Category
 import com.georgeci.moneysurfer.domain.model.CategorySpendHistory
 import com.georgeci.moneysurfer.domain.model.Transaction
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.CategoryType
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 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.usecase.GetCategoriesUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCategorySpendHistoryUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetTransactionsByCategoryUseCase
 import com.georgeci.moneysurfer.navigation.DeleteTransactionWithUndo
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.flow.combine
 import kotlinx.datetime.Month
 import kotlinx.datetime.YearMonth
 import moneysurfer.feature.category.generated.resources.Res
 import moneysurfer.feature.category.generated.resources.category_details_transaction_delete_undo
 import moneysurfer.feature.category.generated.resources.category_details_transaction_deleted_snackbar
 import org.koin.core.annotation.KoinViewModel
 
 @KoinViewModel
 class CategoryDetailsViewModel(
     categoryId: CategoryId,
     private val getCategories: GetCategoriesUseCase,
     private val getCategorySpendHistory: GetCategorySpendHistoryUseCase,
     private val getTransactionsByCategory: GetTransactionsByCategoryUseCase,
     private val deleteWithUndo: DeleteTransactionWithUndo,
 ) : MviViewModel<CategoryDetailsState, CategoryDetailsEvent, CategoryDetailsEffect>(
     initialState = CategoryDetailsState.Loading(categoryId),
 ) {
 
     init {
         observe()
     }
 
     override fun onEvent(event: CategoryDetailsEvent) {
         when (event) {
             CategoryDetailsEvent.OnBackClick -> postSideEffect(CategoryDetailsEffect.NavigateBack)
             CategoryDetailsEvent.OnEditClick ->
                 postSideEffect(CategoryDetailsEffect.NavigateToCategoryEdit(currentState.categoryId))
             CategoryDetailsEvent.OnAddTransactionClick ->
                 postSideEffect(CategoryDetailsEffect.NavigateToTransactionCreation)
             is CategoryDetailsEvent.OnSubcategoryClick ->
                 postSideEffect(CategoryDetailsEffect.NavigateToCategoryDetails(event.categoryId))
             is CategoryDetailsEvent.OnTransactionClick ->
                 postSideEffect(CategoryDetailsEffect.NavigateToTransactionDetails(event.transactionId))
             // The list is a database Flow, so the row leaves — and returns on Undo — on its own.
             is CategoryDetailsEvent.OnDeleteTransaction -> launch {
                 deleteWithUndo(
                     transactionId = event.transactionId,
                     message = Res.string.category_details_transaction_deleted_snackbar,
                     undoLabel = Res.string.category_details_transaction_delete_undo,
                 )
             }
         }
     }
 
     private fun observe() {
         val categoryId = currentState.categoryId
         launch {
             combine(
                 getCategories(),
                 getCategorySpendHistory(categoryId),
                 getTransactionsByCategory(categoryId),
             ) { categories, history, transactions ->
                 Triple(categories, history, transactions)
             }.collect { (categories, history, transactions) ->
                 val category = categories.firstOrNull { it.id == categoryId }
                 // The category can vanish under an open screen — a delete on this device, or a
                 // sync pull from another. Falling back to Missing gives the screen something
                 // honest to render instead of a half-populated page about nothing.
                 if (category == null) {
                     updateState { CategoryDetailsState.Missing(categoryId) }
                     return@collect
                 }
                 val content = buildContent(category, categories, history, transactions)
                 updateState { content }
             }
         }
     }
 
     private fun buildContent(
         category: Category,
         categories: List<Category>,
         history: CategorySpendHistory,
         transactions: List<Transaction>,
     ): CategoryDetailsState.Content {
         // Currency follows the transactions actually booked here. A category with none yet has
         // nothing to read it from, so it falls back to USD — the same placeholder Account
         // Details uses, and it is replaced as soon as the first transaction lands.
         val currency = transactions.firstOrNull()?.currencyCode ?: DefaultCurrency
         val byId = categories.associateBy { it.id }
         val parentTotal = history.currentMonth
 
         return CategoryDetailsState.Content(
             categoryId = category.id,
             name = category.name,
             type = category.type,
             iconKey = category.iconKey,
             hue = category.hue,
             systemKind = category.systemKind?.name,
             isLeaf = history.subcategories.isEmpty(),
             formattedTotal = MoneyFormatter.format(parentTotal, currency),
             formattedAverage = MoneyFormatter.format(history.monthlyAverage, currency),
             formattedPerTransaction = history.perTransaction?.let {
                 MoneyFormatter.format(it, currency)
             },
             transactionCount = history.transactionCount,
             months = history.months.map { month ->
                 CategoryTrendMonthUi(
                     month = month.month,
                     minorValue = month.total.minor,
                     formattedValue = MoneyFormatter.format(month.total, currency),
                 )
             },
             subcategories = history.subcategories.map { child ->
                 CategorySubcategoryUi(
                     id = child.categoryId,
                     name = byId[child.categoryId]?.name.orEmpty(),
                     iconKey = byId[child.categoryId]?.iconKey.orEmpty(),
                     hue = byId[child.categoryId]?.hue ?: UnsetHue,
                     formattedAmount = MoneyFormatter.format(child.total, currency),
                     share = shareOf(child.total, parentTotal),
                 )
             },
             transactions = transactions.map { it.toUi(currency) },
         )
     }
 
     private fun Transaction.toUi(currency: CurrencyCode) = CategoryTransactionUi(
         id = id,
         title = note,
         formattedAmount = MoneyFormatter.format(money.abs(), currency),
         isExpense = type == TransactionType.EXPENSE,
         categoryHueSeed = categoryId?.value.orEmpty(),
         isTransfer = transferId != null,
         isSplitLeg = splitId != null,
     )
 
     private companion object {
         val DefaultCurrency = CurrencyCode("USD")
 
         /** Matches `CategoryAppearance.UNSET_HUE` — the palette resolves it to the id-hashed default. */
         const val UnsetHue = -1
 
         /**
          * A child's slice of the parent. Zero when the parent booked nothing, rather than a
          * division by zero — with no total there is no share to draw.
          */
         fun shareOf(part: Money, whole: Money): Float =
             if (whole.minor <= 0L) 0f else (part.minor.toFloat() / whole.minor).coerceIn(0f, 1f)
     }
 }
 
 @optics
 sealed interface CategoryDetailsState {
     val categoryId: CategoryId
 
     @optics
     data class Loading(override val categoryId: CategoryId) : CategoryDetailsState {
         companion object
     }
 
     /** The category is gone — deleted here or removed by a sync pull while the screen was open. */
     @optics
     data class Missing(override val categoryId: CategoryId) : CategoryDetailsState {
         companion object
     }
 
     @optics
     data class Content(
         override val categoryId: CategoryId,
         val name: String,
         val type: CategoryType,
         val iconKey: String,
         val hue: Int,
         val systemKind: String?,
         val isLeaf: Boolean,
         val formattedTotal: String,
         val formattedAverage: String,
         val formattedPerTransaction: String?,
         val transactionCount: Int,
         val months: List<CategoryTrendMonthUi>,
         val subcategories: List<CategorySubcategoryUi>,
         val transactions: List<CategoryTransactionUi>,
     ) : CategoryDetailsState {
         companion object
     }
 
     companion object
 }
 
 /** One trend column. [minorValue] stays raw so the bar can scale against its siblings. */
 data class CategoryTrendMonthUi(
     val month: YearMonth,
     val minorValue: Long,
     val formattedValue: String,
 ) {
     val monthOfYear: Month get() = month.month
 }
 
 data class CategorySubcategoryUi(
     val id: CategoryId,
     val name: String,
     val iconKey: String,
     val hue: Int,
     val formattedAmount: String,
     val share: Float,
 )
 
 data class CategoryTransactionUi(
     val id: TransactionId,
     val title: String,
     val formattedAmount: String,
     val isExpense: Boolean,
     val categoryHueSeed: String,
     /** One leg of a transfer, which deleting takes down together with its sibling. */
     val isTransfer: Boolean = false,
     /**
      * One leg of a receipt split across categories. The legs are listed separately here — that is
      * the point of a category screen — but deleting one takes the whole receipt, so the
      * confirmation has to say so.
      */
     val isSplitLeg: Boolean = false,
 )
 
 sealed interface CategoryDetailsEvent {
     data object OnBackClick : CategoryDetailsEvent
     data object OnEditClick : CategoryDetailsEvent
     data object OnAddTransactionClick : CategoryDetailsEvent
     data class OnSubcategoryClick(val categoryId: CategoryId) : CategoryDetailsEvent
     data class OnTransactionClick(val transactionId: TransactionId) : CategoryDetailsEvent
 
     /** A row was swiped away and the confirmation accepted; the Undo rides on the snackbar. */
     data class OnDeleteTransaction(val transactionId: TransactionId) : CategoryDetailsEvent
 }
 
 sealed interface CategoryDetailsEffect {
     data object NavigateBack : CategoryDetailsEffect
     data object NavigateToTransactionCreation : CategoryDetailsEffect
     data class NavigateToCategoryEdit(val categoryId: CategoryId) : CategoryDetailsEffect
     data class NavigateToCategoryDetails(val categoryId: CategoryId) : CategoryDetailsEffect
     data class NavigateToTransactionDetails(val transactionId: TransactionId) : CategoryDetailsEffect
 }