Coverage Summary for Class: CompiledFilters (com.georgeci.moneysurfer.feature.transaction.filter)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CompiledFilters |
100%
(5/5)
|
94.4%
(51/54)
|
100%
(30/30)
|
97.4%
(223/229)
|
| CompiledFilters$WhenMappings |
|
| Total |
100%
(5/5)
|
94.4%
(51/54)
|
100%
(30/30)
|
97.4%
(223/229)
|
package com.georgeci.moneysurfer.feature.transaction.filter
import com.georgeci.moneysurfer.domain.model.CategorizedTransaction
import com.georgeci.moneysurfer.domain.model.Transaction
import com.georgeci.moneysurfer.domain.preferences.TransactionPeriodMode
import com.georgeci.moneysurfer.domain.primitives.TransactionStatus
import com.georgeci.moneysurfer.domain.primitives.TransactionType
import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
import com.georgeci.moneysurfer.domain.util.periodWindow
import com.georgeci.moneysurfer.domain.util.startOfIsoWeek
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.plus
private const val MINOR_PER_MAJOR = 100L
private const val MAX_DECIMALS = 2
private const val DAYS_PER_WEEK = 7
/** `Money.MAX_MINOR` is 10^15 minor units, i.e. 13 digits of major units. */
private const val MAX_WHOLE_DIGITS = 13
private const val DECEMBER = 12
private const val DAYS_IN_DECEMBER = 31
private const val DECIMAL_RADIX = 10
/**
* The one date window the list loads, whoever asked for it.
*
* [TransactionDateRange.FollowPeriod] defers to the pager ([mode] + [anchor]); every other range
* takes over completely. The pager is hidden while it is not the source, so the two can never end
* up as competing windows.
*/
fun resolveWindow(
dateRange: TransactionDateRange,
mode: TransactionPeriodMode,
anchor: LocalDate,
today: LocalDate,
): TransactionPeriodWindow = when (dateRange) {
TransactionDateRange.FollowPeriod -> periodWindow(mode, anchor)
is TransactionDateRange.Preset -> presetWindow(dateRange.preset, today)
is TransactionDateRange.Custom -> TransactionPeriodWindow(from = dateRange.from, to = dateRange.to)
}
private fun presetWindow(preset: TransactionDatePreset, today: LocalDate): TransactionPeriodWindow =
when (preset) {
TransactionDatePreset.Today -> TransactionPeriodWindow(today, today)
TransactionDatePreset.Yesterday -> today.minus(1, DateTimeUnit.DAY)
.let { TransactionPeriodWindow(it, it) }
TransactionDatePreset.ThisWeek -> today.startOfIsoWeek()
.let { TransactionPeriodWindow(it, it.plus(DAYS_PER_WEEK - 1, DateTimeUnit.DAY)) }
TransactionDatePreset.ThisMonth -> periodWindow(TransactionPeriodMode.Month, today)
TransactionDatePreset.LastMonth -> periodWindow(
TransactionPeriodMode.Month,
LocalDate(today.year, today.month, 1).minus(1, DateTimeUnit.MONTH),
)
TransactionDatePreset.ThisYear -> TransactionPeriodWindow(
from = LocalDate(today.year, 1, 1),
to = LocalDate(today.year, DECEMBER, DAYS_IN_DECEMBER),
)
}
/**
* An amount the user typed, as an inclusive range of minor units.
*
* Typed precision is the tolerance: "12" means "twelve-something" (1200..1299), "12.5" means
* "twelve fifty-something" (1250..1259), "12.50" is exact. Without that rule a search for the
* amount on a receipt would only ever hit whole-unit transactions, and the min/max fields would
* exclude a €12.99 charge from a "max 12" bound the user meant as "no more than twelve-ish".
*/
data class AmountRange(val fromMinor: Long, val toMinor: Long)
/**
* Parses [raw] as an amount, or returns null when it is not one.
*
* Currency symbols, spaces and thousands separators are dropped, and both `.` and `,` read as the
* decimal separator — a filter field is not the place to be strict about how a number is written.
* More than [MAX_DECIMALS] fraction digits is rejected rather than rounded: it is not an amount
* this app can hold, so silently matching a truncated one would be a lie.
*/
fun parseAmountQuery(raw: String): AmountRange? {
val normalized = normalizeAmountInput(raw) ?: return null
val whole = normalized.substringBefore('.')
val fraction = normalized.substringAfter('.', missingDelimiterValue = "")
val from = whole.toLong() * MINOR_PER_MAJOR + fraction.padEnd(MAX_DECIMALS, '0').toLong()
// Everything the untyped digits could still be: "12" covers 12.00 through 12.99.
val slack = pow10(MAX_DECIMALS - fraction.length) - 1
return AmountRange(fromMinor = from, toMinor = from + slack)
}
/**
* `raw` reduced to `digits[.digits]`, or null when it is not an amount at all. Rejects rather
* than clamps anything wider than the domain's own cap ([MAX_WHOLE_DIGITS] major units), so the
* arithmetic above cannot overflow on a pasted wall of digits.
*/
private fun normalizeAmountInput(raw: String): String? {
val cleaned = raw.trim()
.filterNot { it.isWhitespace() || it == '\'' || it == '_' }
.removePrefix("+")
.replace(',', '.')
val whole = cleaned.substringBefore('.')
val fraction = cleaned.substringAfter('.', missingDelimiterValue = "")
val wellFormed = cleaned.all { it.isDigit() || it == '.' } &&
cleaned.count { it == '.' } <= 1 &&
whole.isNotEmpty() &&
whole.length <= MAX_WHOLE_DIGITS &&
fraction.length <= MAX_DECIMALS
return cleaned.takeIf { wellFormed }
}
private fun pow10(exponent: Int): Long = generateSequence(1L) { it * DECIMAL_RADIX }.elementAt(exponent)
/**
* A [TransactionFilters] with its three free-text amount fields parsed exactly once, ready to test
* many rows.
*
* The min/max bounds and the search-amount are constant across a whole page, so parsing them per
* row (as a naive `matches` loop would) repeats the same parse hundreds of times per rebuild.
* [TransactionFilters.compile] does it once; the per-row [matches] then only compares.
*/
class CompiledFilters internal constructor(
private val filters: TransactionFilters,
private val minMinor: Long?,
private val maxMinor: Long?,
private val queryAmount: AmountRange?,
private val needle: String,
) {
/**
* Whether [row] survives these filters.
*
* The date window is *not* checked here — it is applied by the query (see [resolveWindow]), so
* re-checking it would only duplicate a bound the database already honoured.
*
* `accountIds` is expected to be empty when the list is already scoped to one account; the
* ViewModel clears it there, because intersecting a per-account list with a different account
* would silently show nothing.
*/
fun matches(row: CategorizedTransaction): Boolean {
val transaction = row.transaction
return matchesType(transaction) &&
(filters.accountIds.isEmpty() || transaction.accountId in filters.accountIds) &&
(filters.categoryIds.isEmpty() || transaction.categoryId in filters.categoryIds) &&
matchesAmountBounds(transaction) &&
(!filters.recurringOnly || transaction.recurringRuleId != null) &&
(!filters.plannedOnly || transaction.status == TransactionStatus.PLANNED) &&
matchesQuery(row)
}
/**
* The stored type alone cannot answer this: both legs of a transfer are an ordinary
* `EXPENSE`/`INCOME` pair, so reading only [Transaction.type] would leave `Expenses` showing
* the outgoing leg the user picked the segment to hide. `transferId` is what separates money
* that left the books from money that only moved sideways, and it keeps the four segments
* disjoint.
*/
private fun matchesType(transaction: Transaction): Boolean {
val isTransferLeg = transaction.transferId != null
return when (filters.type) {
TransactionTypeFilter.All -> true
TransactionTypeFilter.Expenses -> !isTransferLeg && transaction.type == TransactionType.EXPENSE
TransactionTypeFilter.Income -> !isTransferLeg && transaction.type == TransactionType.INCOME
TransactionTypeFilter.Transfer -> isTransferLeg
}
}
/**
* Bounds compare against the *magnitude*: the sign is the type filter's business, and a user
* asking for "at least 50" means fifty of money, not −50 being below the bound.
*
* An unparseable bound is ignored rather than treated as zero, and a trailing separator reads
* as the digits before it, so neither nonsense nor a half-typed "12." empties the list.
*/
private fun matchesAmountBounds(transaction: Transaction): Boolean {
val minor = transaction.money.abs().minor
return (minMinor == null || minor >= minMinor) && (maxMinor == null || minor <= maxMinor)
}
/**
* Free-text search over what the user can actually see or remember about a row: merchant, note,
* category, tags — and the amount, which is matched numerically rather than as text so that
* "12" finds €12.40 without also finding every note containing "12".
*/
private fun matchesQuery(row: CategorizedTransaction): Boolean {
if (needle.isEmpty()) return true
val transaction = row.transaction
val textHit = sequenceOf(transaction.merchant, transaction.note, row.categoryName.orEmpty())
.plus(transaction.tags)
.any { it.contains(needle, ignoreCase = true) }
val amountHit = queryAmount != null &&
transaction.money.abs().minor in queryAmount.fromMinor..queryAmount.toMinor
return textHit || amountHit
}
}
/** Parses the amount fields once so a per-row loop only compares — see [CompiledFilters]. */
fun TransactionFilters.compile(): CompiledFilters {
val trimmed = query.trim()
return CompiledFilters(
filters = this,
minMinor = parseAmountQuery(minAmount)?.fromMinor,
maxMinor = parseAmountQuery(maxAmount)?.toMinor,
queryAmount = trimmed.takeIf { it.isNotEmpty() }?.let(::parseAmountQuery),
needle = trimmed,
)
}
/** Single-row convenience — compiles then tests. For a page of rows, [compile] once and reuse. */
fun TransactionFilters.matches(row: CategorizedTransaction): Boolean = compile().matches(row)