Coverage Summary for Class: TransactionsListMappingKt (com.georgeci.moneysurfer.feature.transaction.list)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| TransactionsListMappingKt |
100%
(1/1)
|
100%
(8/8)
|
82.3%
(51/62)
|
100%
(67/67)
|
95.7%
(509/532)
|
package com.georgeci.moneysurfer.feature.transaction.list
import com.georgeci.moneysurfer.domain.formatter.MoneyFormatter
import com.georgeci.moneysurfer.domain.model.Account
import com.georgeci.moneysurfer.domain.model.CategorizedTransaction
import com.georgeci.moneysurfer.domain.model.Category
import com.georgeci.moneysurfer.domain.model.TransactionTotal
import com.georgeci.moneysurfer.domain.primitives.AccountId
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.domain.primitives.SplitId
import com.georgeci.moneysurfer.domain.primitives.TransactionType
import com.georgeci.moneysurfer.feature.transaction.filter.TransactionFilters
import kotlinx.datetime.LocalDate
/**
* The chip rail's values. Names are resolved from the account and category lists rather than in
* the screen, which has neither; a single selection shows its name, several show their count, and
* nothing selected leaves the chip in its bare "Account" state.
*/
internal fun chips(
filters: TransactionFilters,
accounts: List<Account>,
categories: List<Category>,
): TransactionFilterChipsUi = TransactionFilterChipsUi(
dateRange = filters.dateRange,
type = filters.type,
accountCount = filters.accountIds.size,
accountName = filters.accountIds.singleOrNull()
?.let { id -> accounts.firstOrNull { it.id == id }?.name },
categoryCount = filters.categoryIds.size,
categoryName = filters.categoryIds.singleOrNull()
?.let { id -> categories.firstOrNull { it.id == id }?.name },
sort = filters.sort,
)
internal fun groupByDate(
rows: List<CategorizedTransaction>,
accountNames: Map<AccountId, String>,
dateLabel: (LocalDate) -> TransactionDateUi,
): List<TransactionGroupUi> =
collapseSplitLegs(rows)
.groupBy { it.primary.transaction.operationDate }
.map { (date, receipts) ->
val net = receipts.fold(Money.zero()) { acc, receipt -> acc + receipt.signedMoney() }
TransactionGroupUi(
date = date,
dateLabel = dateLabel(date),
netFormatted = MoneyFormatter.format(
net,
receipts.first().primary.transaction.currencyCode,
),
netPositive = !net.isNegative(),
transactions = receipts.map { it.toRow(accountNames) },
)
}
/**
* One line of the list: an ordinary transaction, or every leg of a split the page holds in full.
*
* The legs of a split share account, date and type by construction (see
* `CreateSplitTransactionUseCase`), so a receipt has one date to group under and one currency to
* format in.
*/
internal data class ListReceipt(val legs: List<CategorizedTransaction>) {
val primary: CategorizedTransaction get() = legs.first()
val isSplit: Boolean get() = legs.size > 1
/** Distinct categories the receipt was filed under — what the collapsed row's badge counts. */
val categoryCount: Int get() = legs.map { it.transaction.categoryId }.distinct().size
}
/**
* Collapses the legs of a split into a single receipt — a supermarket run paid for once should be
* one row, not one row per category it was filed under.
*
* **Only a group present in full collapses.** A page is a `LIMIT` window and may be narrowed
* further by an in-memory filter, so a group can easily be here in part: cut by the paging
* boundary, or thinned down to the one leg a category filter matched. Collapsing those would render
* a total quietly missing money — a figure that changes on its own when the user taps "load more".
* Rendering the legs it actually has instead is honest at every window size, and it is also what
* the account- and category-scoped lists want, where a leg genuinely is its own row.
*
* [CategorizedTransaction.splitLegCount] is the group's size in storage, counted by the window
* query, which is what makes "present in full" answerable without a second round trip.
*/
internal fun collapseSplitLegs(rows: List<CategorizedTransaction>): List<ListReceipt> {
val presentLegs = rows
.filter { it.transaction.splitId != null }
.groupBy { it.transaction.splitId }
val collapsed = mutableSetOf<SplitId>()
return rows.mapNotNull { row ->
val splitId = row.transaction.splitId ?: return@mapNotNull ListReceipt(listOf(row))
val legs = presentLegs[splitId].orEmpty()
if (legs.size < row.splitLegCount) return@mapNotNull ListReceipt(listOf(row))
if (!collapsed.add(splitId)) return@mapNotNull null
ListReceipt(legs)
}
}
private fun ListReceipt.toRow(accountNames: Map<AccountId, String>): TransactionRowUi {
val transaction = primary.transaction
val isTransferLeg = transaction.transferId != null
val total = legs.fold(Money.zero()) { acc, leg -> acc + leg.transaction.money.abs() }
return TransactionRowUi(
id = transaction.id,
// Merchant first: "Starbucks" identifies the row better than whatever was jotted next
// to it, and the note still shows when there is no merchant.
title = transaction.merchant.ifBlank { transaction.note }
.ifBlank { primary.categoryName.orEmpty() },
// A collapsed receipt names no single category — the screen replaces this with the
// category count, which is the one thing the row can say truthfully about all of them.
subtitle = primary.categoryName.orEmpty(),
splitCategoryCount = if (isSplit) categoryCount else 0,
isSplitLeg = transaction.splitId != null,
accountName = accountNames[transaction.accountId].orEmpty(),
formattedAmount = MoneyFormatter.format(total, transaction.currencyCode),
isExpense = transaction.type == TransactionType.EXPENSE,
isTransfer = isTransferLeg,
// A transfer leg is drawn from the shared transfer palette instead of its category's
// hue, so it carries no seed — see how the screen renders it.
categoryHueSeed = if (isTransferLeg) {
""
} else {
primary.categoryName ?: transaction.categoryId?.value.orEmpty()
},
)
}
/** The receipt's contribution to the day's net — every leg of it, signed by its type. */
private fun ListReceipt.signedMoney(): Money =
legs.fold(Money.zero()) { acc, leg ->
val magnitude = leg.transaction.money.abs()
acc + if (leg.transaction.type == TransactionType.EXPENSE) -magnitude else magnitude
}
/**
* The single currency the summary strip renders in.
*
* Scoped to an account it is simply that account's currency. In the all-accounts view there is
* no one right answer, so the currency with the largest total magnitude is chosen — a
* deterministic, recency-independent pick, so a newly-added transaction in another currency
* cannot flip the strip the way keying off the newest visible row did. Amounts in the other
* currencies are still excluded from the total (see [buildSummary]); a per-currency summary
* would be a larger design change than this screen owns.
*/
internal fun summaryCurrency(account: Account?, totals: List<TransactionTotal>): CurrencyCode =
account?.currencyCode
?: totals.maxByOrNull { it.total.minor }?.currencyCode
?: CurrencyCode("USD")
/**
* Summary of the whole period, not of the loaded page: [totals] come straight from the
* database aggregation, so paging cannot change these numbers.
*
* It also deliberately ignores the filters. The strip answers "what happened in this period",
* which stays true while the user narrows the list below it; recomputing it from the matched
* page would instead answer "what is on screen", and would drift as pages load.
*
* Rows in other currencies are dropped rather than added to the displayed one — mixing minor
* units across currencies would produce a confidently wrong number.
*/
internal fun buildSummary(
totals: List<TransactionTotal>,
currency: CurrencyCode,
): TransactionSummaryUi {
val inCurrency = totals.filter { it.currencyCode == currency }
val income = inCurrency.sumOfMoney { it.type == TransactionType.INCOME }
val expense = inCurrency.sumOfMoney { it.type == TransactionType.EXPENSE }
val net = income - expense
return TransactionSummaryUi(
incomeFormatted = "+" + MoneyFormatter.format(income, currency),
expenseFormatted = "−" + MoneyFormatter.format(expense, currency),
netFormatted = (if (net.isNegative()) "−" else "+") +
MoneyFormatter.format(net.abs(), currency),
netPositive = !net.isNegative(),
)
}
private fun List<TransactionTotal>.sumOfMoney(predicate: (TransactionTotal) -> Boolean): Money =
filter(predicate).fold(Money.zero()) { acc, total -> acc + total.total }