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:
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user