M12c: category contract v2 on Android (14 defaults, suggestCategory)

- :core:model: DefaultCategoryKey in AC-7.2 order (14 keys: housing, insurance, children, fees added),
  ColorToken + SLATE, AMBER, CategoryIcons.ALL + key, shield, account_balance (33),
  ExpenseRepository.suggestCategory(accountId, description); ContractTest updated.
- :domain: parseCategoryKeywords (pure, shared by :data and :core:fakes) with a parser test; the
  suggestCategory records and rule unchanged, suggest_category vectors pass.
- :data: PlatformUnicodeText (java.text.Normalizer, Character), CategoryKeywords parsed once from the
  Java resource that copyCategoryKeywords copies from contracts/category-keywords.csv at build time
  (missing file fails the build), ExpenseRepositoryImpl.suggestCategory over the Room snapshot and the
  DataStore last-used category; tests: byte identity and the 14.8 rules of the bundled table, the
  UnicodeText binding, and Room/Robolectric cases for history, first token, ties, keywords, fallback,
  archived categories, deleted expenses, both users and accounts, null account.
- :core:fakes: the 14 defaults with the schema v2 ids, fake suggestCategory through :domain with its own
  build-time copy of the table; tests.
- Room schema unchanged (version 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:21:53 +02:00
parent d39eb2c79b
commit b09f3220a1
18 changed files with 608 additions and 40 deletions
@@ -41,6 +41,7 @@ import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.repo.AboutRepositoryImpl
import se.hagstedt.kvitt.data.repo.AccountRepositoryImpl
import se.hagstedt.kvitt.data.repo.AnalyticsRepositoryImpl
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
@@ -229,8 +230,18 @@ object DataModule {
AccountRepositoryImpl(local, api, gate, db, clock)
@Provides @Singleton
fun expenses(local: LocalData, db: KvittDatabase, prefs: PhonePrefs, clock: Clock, ids: IdGenerator, triggers: SyncTriggers): ExpenseRepositoryImpl =
ExpenseRepositoryImpl(local, db, prefs, clock, ids, triggers)
fun categoryKeywords(): CategoryKeywords = CategoryKeywords()
@Provides @Singleton
fun expenses(
local: LocalData,
db: KvittDatabase,
prefs: PhonePrefs,
clock: Clock,
ids: IdGenerator,
triggers: SyncTriggers,
keywords: CategoryKeywords,
): ExpenseRepositoryImpl = ExpenseRepositoryImpl(local, db, prefs, clock, ids, triggers, keywords)
@Provides @Singleton
fun settlements(local: LocalData, db: KvittDatabase, clock: Clock, ids: IdGenerator, triggers: SyncTriggers): SettlementRepositoryImpl =
@@ -0,0 +1,65 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.domain.ActiveCategory
import se.hagstedt.kvitt.domain.CategoryKeyword
import se.hagstedt.kvitt.domain.SuggestionHistoryEntry
import se.hagstedt.kvitt.domain.UnicodeText
import se.hagstedt.kvitt.domain.parseCategoryKeywords
import se.hagstedt.kvitt.domain.suggestCategory
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import java.text.Normalizer
/** The platform's Unicode services for CS-4 (NFC, general categories). */
object PlatformUnicodeText : UnicodeText {
override fun nfc(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC)
override fun isLetter(codePoint: Int): Boolean = Character.isLetter(codePoint)
override fun isDigit(codePoint: Int): Boolean = Character.getType(codePoint) == Character.DECIMAL_DIGIT_NUMBER.toInt()
}
/**
* The keyword table of CS-6: the Java resource [RESOURCE], copied from contracts/category-keywords.csv at build time
* by `:data:copyCategoryKeywords`, parsed once on first use.
*/
class CategoryKeywords(private val source: () -> String = ::bundledTable) {
val entries: List<CategoryKeyword> by lazy { parseCategoryKeywords(source()) }
companion object {
const val RESOURCE = "/se/hagstedt/kvitt/data/category-keywords.csv"
/** The bundled table as text (UTF-8). */
fun bundledTable(): String = checkNotNull(CategoryKeywords::class.java.getResourceAsStream(RESOURCE)) {
"category keyword table $RESOURCE is not on the class path"
}.use { it.readBytes().decodeToString() }
}
}
/**
* UX-3 over one snapshot: history is every non-deleted expense on the phone (all accounts, both users, pending ones
* included), active categories are the ones neither archived nor deleted, and the fallback is the AC-8.6 default for
* [accountId] ([lastCategoryFor] if still active, else `other`; `other` when no account is selected).
*/
internal fun Snapshot.suggestCategory(
accountId: AccountId?,
description: String,
lastCategoryFor: Map<String, String>,
keywords: List<CategoryKeyword>,
): CategoryId? {
val active = categoryRows.filter { it.archivedAt == null && it.deletedAt == null }
val activeIds = active.map { it.id }.toSet()
val last = accountId?.let { lastCategoryFor[it.value] }?.takeIf { it in activeIds }
val fallback = (last ?: active.firstOrNull { it.defaultKey == OTHER_KEY }?.id)?.let { CategoryId(it) }
return suggestCategory(
description = description,
history = liveExpenses.map { SuggestionHistoryEntry(it.row.description, CategoryId(it.row.categoryId), LocalDate.parse(it.row.expenseDate), it.row.createdAt) },
keywords = keywords,
activeCategories = active.map { ActiveCategory(CategoryId(it.id), it.defaultKey) },
fallback = fallback,
unicode = PlatformUnicodeText,
)
}
private const val OTHER_KEY = "other"
@@ -21,6 +21,8 @@ import se.hagstedt.kvitt.data.util.nowTimestamp
import se.hagstedt.kvitt.data.util.today
import se.hagstedt.kvitt.domain.BP_TOTAL
import se.hagstedt.kvitt.domain.computeSplit
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseDraft
@@ -44,6 +46,7 @@ class ExpenseRepositoryImpl(
private val clock: Clock,
private val ids: IdGenerator,
private val writes: LocalWriteListener,
private val keywords: CategoryKeywords,
) : ExpenseRepository {
private val timelines = Timelines(db, local)
@@ -73,8 +76,8 @@ class ExpenseRepositoryImpl(
val e = local.snapshot.first().expense(id.value)?.takeIf { !it.deleted } ?: return null
return ExpenseDraft(
id = e.id,
accountId = se.hagstedt.kvitt.model.AccountId(e.row.accountId),
categoryId = se.hagstedt.kvitt.model.CategoryId(e.row.categoryId),
accountId = AccountId(e.row.accountId),
categoryId = CategoryId(e.row.categoryId),
amountOre = e.row.amountOre,
description = e.row.description,
notes = e.row.notes,
@@ -181,6 +184,10 @@ class ExpenseRepositoryImpl(
)
}.distinctUntilChanged()
/** UX-3 (CS-5): a local computation over the Room snapshot and the bundled keyword table; no network (CS-3). */
override suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? =
local.snapshot.first().suggestCategory(accountId, description, prefs.editorDefaults.first().lastCategoryFor, keywords.entries)
companion object {
const val REQUIRED = "required"