Coverage Summary for Class: TransactionCreationMappingKt (com.georgeci.moneysurfer.feature.transaction.creation)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| TransactionCreationMappingKt |
100%
(7/7)
|
97.7%
(85/87)
|
100%
(62/62)
|
99.1%
(550/555)
|
| TransactionCreationMappingKt$buildDisplayCategories$$inlined$sortedByDescending$1 |
0%
(0/1)
|
|
| TransactionCreationMappingKt$WhenMappings |
|
| Total |
87.5%
(7/8)
|
97.7%
(85/87)
|
100%
(62/62)
|
99.1%
(550/555)
|
package com.georgeci.moneysurfer.feature.transaction.creation
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.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.domain.primitives.TransactionType
private const val CATEGORY_PREVIEW_SIZE = 7
/**
* Fills the blank form from an existing transaction.
*
* In [TransactionCreationSeed.Mode.Edit] that means the row itself, identity and timestamps
* included. A duplicate copies only what the user typed — no id, no `createdAt`, and today's
* date rather than the original's: it is a new transaction that merely starts out looking
* like an old one.
*/
internal fun TransactionCreationState.Content.seededFrom(
seed: TransactionCreationSeed,
transaction: Transaction,
): TransactionCreationState.Content {
val account = accounts.find { it.id == transaction.accountId }
val category = categories.find { it.id == transaction.categoryId }
val resolvedType = when (transaction.type) {
TransactionType.INCOME -> TransactionTypeUi.Income
else -> TransactionTypeUi.Expense
}
val resolvedSelected = category ?: selectedCategory
val editing = seed.mode == TransactionCreationSeed.Mode.Edit
return copy(
amount = transaction.money.toAmountInput(),
note = transaction.note,
type = resolvedType,
selectedAccount = account ?: selectedAccount,
selectedCategory = resolvedSelected,
timestamp = if (editing) transaction.operationAt.toEpochMilliseconds() else timestamp,
isEditMode = editing,
editingTransactionId = seed.transactionId.takeIf { editing },
editingCreatedAt = transaction.createdAt.takeIf { editing },
pinnedOperationDate = transaction.operationDate.takeIf { editing },
editIdentity = if (editing) identityOf(transaction, category) else null,
preserved = if (editing) {
PreservedTransactionFields.of(transaction)
} else {
// A duplicate keeps what the user typed about the counterparty, but is a fresh
// manual entry: it inherits neither the transfer pairing (a second leg would be
// missing), the split group (it would silently add a leg to someone else's receipt),
// the recurring rule that generated the original, nor its planned state.
PreservedTransactionFields(merchant = transaction.merchant, tags = transaction.tags)
},
displayCategories = buildDisplayCategories(
categories = categories,
counts = categoryUsageCounts,
type = resolvedType.categoryType(),
selected = resolvedSelected,
),
)
}
/** Puts a chosen account in the slot the chooser was opened for — see [AccountSlot]. */
internal fun TransactionCreationState.Content.withAccountInSlot(
account: Account,
slot: AccountSlot,
): TransactionCreationState.Content = when (slot) {
AccountSlot.Single -> copy(selectedAccount = account)
AccountSlot.From -> copy(fromAccount = account)
AccountSlot.To -> copy(toAccount = account)
}
/** Categories the type's picker offers: everything but income belongs to the expense side. */
internal fun TransactionTypeUi.categoryType(): CategoryType =
if (this == TransactionTypeUi.Income) CategoryType.INCOME else CategoryType.EXPENSE
/** Amount as the text field spells it — a whole number keeps no trailing `.0`. */
internal fun Money.toAmountInput(): String {
val major = minor / Money.MINOR_PER_MAJOR
return if (major == major.toLong().toDouble()) major.toLong().toString() else major.toString()
}
/**
* The stored row, frozen for the identity band — the design's `04b · Edit transaction`.
*
* Read from [transaction] rather than from the live form on purpose: the band answers "which
* transaction am I editing", so it has to keep saying what was opened even after the amount or
* the note has been typed over. A transfer leg is labelled as a transfer and left unsigned,
* matching the details screen.
*/
internal fun identityOf(transaction: Transaction, category: Category?): TransactionEditIdentity {
val formatted = MoneyFormatter.format(transaction.money.abs(), transaction.currencyCode)
return TransactionEditIdentity(
reference = transaction.id.reference,
type = when {
transaction.transferId != null -> TransactionTypeUi.Transfer
transaction.type == TransactionType.INCOME -> TransactionTypeUi.Income
else -> TransactionTypeUi.Expense
},
note = transaction.note.ifBlank { transaction.merchant },
formattedAmount = when {
transaction.transferId != null -> formatted
transaction.type == TransactionType.INCOME -> "+$formatted"
transaction.type == TransactionType.EXPENSE -> "−$formatted"
else -> formatted
},
categoryId = category?.id?.value.orEmpty(),
categoryIconKey = category?.iconKey.orEmpty(),
categoryHue = category?.hue ?: CategoryAppearance.UNSET_HUE,
categorySystemKind = category?.systemKind?.name,
)
}
internal fun pickDefaultCategory(
categories: List<Category>,
counts: Map<CategoryId, Int>,
type: CategoryType,
): Category? = categories
.filter { it.type == type }
.maxByOrNull { counts[it.id] ?: 0 }
internal fun buildDisplayCategories(
categories: List<Category>,
counts: Map<CategoryId, Int>,
type: CategoryType,
selected: Category?,
): List<Category> {
val byUsage = categories
.filter { it.type == type }
.sortedByDescending { counts[it.id] ?: 0 }
val top = byUsage.take(CATEGORY_PREVIEW_SIZE)
val resolvedSelected = selected?.takeIf { it.type == type }
return if (resolvedSelected == null || top.any { it.id == resolvedSelected.id }) {
top
} else {
top.take(CATEGORY_PREVIEW_SIZE - 1) + resolvedSelected
}
}