M2b: Android skeleton, :core:model contract, :domain passing all vectors

Gradle project in android/ with every module of SPEC §6.2 (build-logic convention plugins, version catalog,
architecture check in check), :core:model types and repository interfaces of §14.2.4 and §14.5, :domain
implementing §14.5.2 and §14.5.4 (125 vector cases), scenario-driven fakes with the empty scenario, Room 3
entities and KvittDatabase v1 with exported schema, :app shell with Hilt, en/sv strings with MissingTranslation
as a lint error, Roborazzi goldens of the empty screen in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Accounts (S-5, S-6), SPEC §14.2.4.
data class AccountDetailState(val account: Account, val balance: BalanceView, val months: List<LedgerMonth>, val deletable: Boolean, val users: List<User>)
interface AccountRepository {
fun detail(id: AccountId): Flow<AccountDetailState?>
fun all(): Flow<List<Account>>
suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId>
suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit>
suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit>
suspend fun delete(id: AccountId): OnlineResult<Unit>
}
@@ -0,0 +1,21 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Analytics (S-11, S-12), SPEC §14.2.4.
data class AnalyticsFilter(val preset: RangePreset, val customStart: String?, val customEnd: String?, val granularity: Granularity, val measure: Measure, val groupBy: GroupBy)
/** label null: UI uses the default category name. */
data class GroupKey(val id: String, val label: String?, val color: ColorToken?)
data class SpendBucket(val key: String, val start: String, val end: String, val totals: Map<String, Long>, val totalOre: Long)
data class AnalyticsState(val filter: AnalyticsFilter, val start: String, val end: String, val groups: List<GroupKey>, val buckets: List<SpendBucket>, val byCategory: List<Pair<GroupKey, Long>>, val byAccount: List<Pair<GroupKey, Long>>, val totalOre: Long)
data class DrillDownState(val title: String, val totalOre: Long, val rows: List<LedgerRow.ExpenseRow>)
interface AnalyticsRepository {
fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState>
fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState>
}
@@ -0,0 +1,39 @@
package se.hagstedt.kvitt.model
// Enums (SPEC §14.2.4). Wire value of every enum = lowercase name (SplitMode.DEFAULT <-> "default").
enum class Role { ADMIN, MEMBER }
enum class SplitMode { DEFAULT, RATIO, EXACT, FULL }
enum class InviteKind { ADMIN_SETUP, PARTNER, DEVICE }
enum class EntityType { EXPENSE, SETTLEMENT }
enum class AuditAction { CREATE, UPDATE, DELETE }
enum class AuditSource { APP, RECURRING, IMPORT }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOME, UTILITIES, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, SHOPPING, OTHER }
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME }
enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM }
enum class Granularity { MONTH, WEEK }
enum class Measure { HOUSEHOLD, MY_SHARE }
enum class GroupBy { CATEGORY, ACCOUNT }
enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW }
/** Category icon tokens: Material Symbols names, the exhaustive v1 set of SPEC §9.7, in that order. */
object CategoryIcons {
val ALL: List<String> = listOf(
"shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train",
"local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center",
"medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard",
"celebration", "build", "chair", "subscriptions", "category",
)
}
@@ -0,0 +1,25 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Expenses (S-7, S-8), SPEC §14.2.4.
data class ExpenseDraft(val id: ExpenseId?, val accountId: AccountId?, val categoryId: CategoryId?, val amountOre: Long?, val description: String, val notes: String, val date: String, val payer: UserId, val mode: SplitMode, val ratioBp: Map<UserId, Int>?, val exactOre: Map<UserId, Long>?, val fullOn: UserId?)
data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List<Account>, val categories: List<Category>, val defaultAccountId: AccountId?, val defaultCategoryFor: Map<AccountId, CategoryId>, val today: String)
data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List<FieldChange>, val pendingLocal: Boolean)
/** Ids already resolved to names; amounts and dates raw (öre as String, ISO date), formatted by the UI. */
data class FieldChange(val field: String, val oldDisplay: String?, val newDisplay: String?)
data class ExpenseDetailState(val id: ExpenseId, val account: Account, val category: Category, val amountOre: Long, val description: String, val notes: String, val date: String, val payer: User, val mode: SplitMode, val shares: List<Share>, val recurringTemplateName: String?, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
interface ExpenseRepository {
fun editorOptions(): Flow<ExpenseEditorOptions>
suspend fun load(id: ExpenseId): ExpenseDraft?
fun preview(draft: ExpenseDraft): List<Share>? // null while the draft cannot be computed
suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId>
suspend fun delete(id: ExpenseId)
fun detail(id: ExpenseId): Flow<ExpenseDetailState?>
}
@@ -0,0 +1,18 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Balances shared by several screens and Home (S-4), SPEC §14.2.4.
/** netOre is the viewer's net (§9.3): > 0 partner owes viewer. */
data class BalanceView(val netOre: Long, val partner: User)
data class AccountSummary(val account: Account, val netOre: Long, val expensesThisMonth: Int)
data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean)
data class HomeState(val me: User, val partner: User?, val global: BalanceView?, val active: List<AccountSummary>, val archived: List<AccountSummary>, val sync: SyncSummary, val update: UpdateInfo?)
interface HomeRepository {
fun home(): Flow<HomeState>
}
@@ -0,0 +1,19 @@
package se.hagstedt.kvitt.model
// Ids (SPEC §14.2.4). Values are UUIDv7 strings on the wire.
@JvmInline value class UserId(val value: String)
@JvmInline value class AccountId(val value: String)
@JvmInline value class CategoryId(val value: String)
@JvmInline value class ExpenseId(val value: String)
@JvmInline value class SettlementId(val value: String)
@JvmInline value class TemplateId(val value: String)
@JvmInline value class DeviceId(val value: String)
@JvmInline value class InviteId(val value: String)
@@ -0,0 +1,17 @@
package se.hagstedt.kvitt.model
// Ledger rows (S-5, S-12), SPEC §14.2.4.
sealed interface ExpenseEffect {
data class Lent(val ore: Long) : ExpenseEffect
data class Borrowed(val ore: Long) : ExpenseEffect
data object NotInvolved : ExpenseEffect
data object PaidForYourself : ExpenseEffect
}
sealed interface LedgerRow {
data class ExpenseRow(val id: ExpenseId, val date: String, val description: String, val category: Category, val amountOre: Long, val effect: ExpenseEffect, val pending: Boolean) : LedgerRow
data class SettlementRow(val id: SettlementId, val date: String, val from: User, val to: User, val amountOre: Long, val accountCount: Int, val pending: Boolean) : LedgerRow
}
data class LedgerMonth(val period: String, val householdSpendOre: Long, val rows: List<LedgerRow>)
@@ -0,0 +1,25 @@
package se.hagstedt.kvitt.model
// Plain records and results (SPEC §14.2.4).
data class User(val id: UserId, val role: Role, val displayName: String, val pictureVersion: Int?)
data class Account(val id: AccountId, val name: String, val defaultShares: Map<UserId, Int>, val createdAt: String, val archived: Boolean)
data class Category(val id: CategoryId, val defaultKey: DefaultCategoryKey?, val customName: String?, val icon: String, val color: ColorToken, val sortOrder: Int, val archived: Boolean)
data class Share(val userId: UserId, val shareOre: Long, val shareBp: Int?)
sealed interface OnlineResult<out T> {
data class Ok<T>(val value: T) : OnlineResult<T>
data object Offline : OnlineResult<Nothing>
data class Failed(val code: String, val field: String?) : OnlineResult<Nothing> // code from §14.3.2
}
sealed interface SaveResult<out T> {
data class Saved<T>(val id: T) : SaveResult<T>
data class Invalid(val errors: List<FieldError>) : SaveResult<Nothing>
}
/** `error` is a domain error token of §14.5.3 or "required". */
data class FieldError(val field: String, val error: String)
@@ -0,0 +1,18 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Session and onboarding (S-1, S-2, S-3, S-18), SPEC §14.2.4.
sealed interface SessionState {
data object Unpaired : SessionState
data class Paired(val me: User, val partner: User?, val deviceId: DeviceId) : SessionState
data object Revoked : SessionState
data object Unrecognised : SessionState // 401 unauthorized: pair again, data kept (§14.4.8)
}
interface SessionRepository {
val session: Flow<SessionState>
suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User>
suspend fun acknowledgeRevoked() // wipes local data (AC-3.3) and moves to Unpaired
}
@@ -0,0 +1,46 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Settings (S-13 to S-15, S-19 to S-22), SPEC §14.2.4.
interface ProfileRepository {
fun household(): Flow<List<User>>
suspend fun setDisplayName(name: String): OnlineResult<Unit>
suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit>
suspend fun removePicture(): OnlineResult<Unit>
}
interface CategoryRepository {
fun all(): Flow<List<Category>>
suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult<CategoryId>
suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult<Unit> // name null = reset default name
suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit>
suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit>
suspend fun delete(id: CategoryId): OnlineResult<Unit>
}
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
interface RecurringRepository {
fun all(): Flow<List<RecurringTemplate>>
suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId>
suspend fun update(t: RecurringTemplate): OnlineResult<Unit>
suspend fun delete(id: TemplateId): OnlineResult<Unit>
}
data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val current: Boolean, val revoked: Boolean)
interface DeviceRepository {
suspend fun list(): OnlineResult<List<Device>>
suspend fun revoke(id: DeviceId): OnlineResult<Unit>
}
/** code and link only right after creation. */
data class Invite(val id: InviteId, val kind: InviteKind, val targetUser: UserId?, val code: String?, val link: String?, val createdAt: String, val expiresAt: String)
interface InviteRepository {
suspend fun list(): OnlineResult<List<Invite>>
suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite>
suspend fun cancel(id: InviteId): OnlineResult<Unit>
}
@@ -0,0 +1,21 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Settlements (S-9, S-10), SPEC §14.2.4.
data class AllocationLine(val account: Account, val payerNetOre: Long, val amountOre: Long, val overSettles: Boolean)
data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List<AllocationLine>, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String)
data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map<AccountId, Long>?, val date: String, val note: String)
data class SettlementDetailState(val id: SettlementId, val from: User, val to: User, val amountOre: Long, val date: String, val note: String, val allocations: List<Pair<Account, Long>>, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
interface SettlementRepository {
suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState // defaults per AC-11.3 / AC-11.4
suspend fun recompute(request: SettleRequest): SettleState // allocations == null: propose (§9.4); else validate and warn
suspend fun save(request: SettleRequest): SaveResult<SettlementId>
suspend fun delete(id: SettlementId)
fun detail(id: SettlementId): Flow<SettlementDetailState?>
}
@@ -0,0 +1,42 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Sync status, update, about and connectivity (S-16, S-17, S-22, §9.9), SPEC §14.2.4.
data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String)
data class SyncStatusState(val summary: SyncSummary, val pending: List<LedgerRow>, val rejections: List<Rejection>)
interface SyncRepository {
fun status(): Flow<SyncStatusState>
suspend fun syncNow()
suspend fun dismiss(mutationId: String)
}
data class UpdateInfo(val versionCode: Int, val versionName: String, val sizeBytes: Long)
sealed interface UpdateState {
data object None : UpdateState
data class Available(val info: UpdateInfo) : UpdateState
data class Downloading(val info: UpdateInfo, val progress: Float) : UpdateState
data class Failed(val info: UpdateInfo) : UpdateState
data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
}
interface UpdateRepository {
val state: Flow<UpdateState>
suspend fun check()
suspend fun install()
}
data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?)
interface AboutRepository {
fun about(): Flow<AboutState>
}
/** §9.9 */
interface ConnectivityRepository {
val online: Flow<Boolean>
}
@@ -0,0 +1,22 @@
package se.hagstedt.kvitt.model.analytics
import kotlinx.datetime.LocalDate
// Analytics input and output types (SPEC §14.5.4), package per the orchestrator's M2b decision.
data class AnalyticsExpense(val date: LocalDate, val accountId: String, val categoryId: String, val amountOre: Long, val shares: Map<String, Long>)
data class DateRange(val start: LocalDate, val endInclusive: LocalDate)
enum class AnalyticsGranularity { MONTH, WEEK }
sealed interface AnalyticsMeasure {
data object Household : AnalyticsMeasure
data class Share(val userId: String) : AnalyticsMeasure
}
enum class AnalyticsGroupBy { CATEGORY, ACCOUNT }
data class Bucket(val key: String, val start: LocalDate, val endInclusive: LocalDate, val totals: Map<String, Long>, val total: Long)
data class SpendSeries(val buckets: List<Bucket>, val groupTotals: Map<String, Long>, val grandTotal: Long)
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.model.money
// SPEC §14.5.1
data class AccountPosition(val accountId: String, val createdAt: String, val archived: Boolean, val payerNetOre: Long)
enum class AllocationError { AMOUNT_OUT_OF_RANGE, NO_ACCOUNTS }
data class Allocation(val accountId: String, val amountOre: Long)
sealed interface AllocationResult {
data class Ok(val allocations: List<Allocation>, val excessOre: Long, val overSettledAccountIds: List<String>) : AllocationResult
data class Err(val error: AllocationError) : AllocationResult
}
enum class SettlementError { AMOUNT_OUT_OF_RANGE, SAME_USER, USER_UNKNOWN, NO_ALLOCATIONS, TOO_MANY_ALLOCATIONS, ALLOCATION_NOT_POSITIVE, DUPLICATE_ACCOUNT, ALLOCATION_SUM_MISMATCH }
@@ -0,0 +1,11 @@
package se.hagstedt.kvitt.model.money
// SPEC §14.5.1
data class BalanceExpense(val accountId: String, val amountOre: Long, val payerUserId: String, val shares: Map<String, Long>)
/** allocations: accountId -> öre. */
data class BalanceSettlement(val fromUserId: String, val toUserId: String, val allocations: Map<String, Long>)
/** perAccount: accountId -> userId -> net; global: userId -> net. */
data class Balances(val perAccount: Map<String, Map<String, Long>>, val global: Map<String, Long>)
@@ -0,0 +1,17 @@
package se.hagstedt.kvitt.model.money
// Domain input and output types (SPEC §14.5.1). Ids are plain strings; money is öre (Long); bp is Int.
sealed interface SplitSpec {
data class Ratio(val bp: Map<String, Int>) : SplitSpec // modes default and ratio
data class Exact(val ore: Map<String, Long>) : SplitSpec // mode exact
data class Full(val bearerUserId: String) : SplitSpec // mode full
}
/** Checked in this order; the first failure is returned (§14.5.2). Wire token = lowercase name (§14.5.3). */
enum class SplitError { AMOUNT_OUT_OF_RANGE, USERS_INVALID, PAYER_UNKNOWN, BP_OUT_OF_RANGE, BP_SUM_NOT_10000, EXACT_NEGATIVE, EXACT_SUM_MISMATCH, BEARER_UNKNOWN, SHARE_USERS_MISMATCH }
sealed interface SplitResult {
data class Ok(val shares: Map<String, Long>) : SplitResult
data class Err(val error: SplitError) : SplitResult
}
@@ -0,0 +1,71 @@
package se.hagstedt.kvitt.model
import org.junit.Assert.assertEquals
import org.junit.Test
import se.hagstedt.kvitt.model.money.AllocationError
import se.hagstedt.kvitt.model.money.SettlementError
import se.hagstedt.kvitt.model.money.SplitError
/** Wire values (lowercase enum names, SPEC §14.2.4, §14.5.3) and the §9.7 token lists. */
class ContractTest {
private fun wire(values: Array<out Enum<*>>) = values.map { it.name.lowercase() }
@Test
fun enumWireValuesMatchTheSpec() {
assertEquals(listOf("admin", "member"), wire(Role.entries.toTypedArray()))
assertEquals(listOf("default", "ratio", "exact", "full"), wire(SplitMode.entries.toTypedArray()))
assertEquals(listOf("admin_setup", "partner", "device"), wire(InviteKind.entries.toTypedArray()))
assertEquals(listOf("expense", "settlement"), wire(EntityType.entries.toTypedArray()))
assertEquals(listOf("create", "update", "delete"), wire(AuditAction.entries.toTypedArray()))
assertEquals(listOf("app", "recurring", "import"), wire(AuditSource.entries.toTypedArray()))
assertEquals(
listOf("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other"),
wire(DefaultCategoryKey.entries.toTypedArray()),
)
assertEquals(
listOf("green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"),
wire(ColorToken.entries.toTypedArray()),
)
assertEquals(
listOf("this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"),
wire(RangePreset.entries.toTypedArray()),
)
assertEquals(listOf("month", "week"), wire(Granularity.entries.toTypedArray()))
assertEquals(listOf("household", "my_share"), wire(Measure.entries.toTypedArray()))
assertEquals(listOf("category", "account"), wire(GroupBy.entries.toTypedArray()))
assertEquals(
listOf("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew"),
wire(SyncRejectionReason.entries.toTypedArray()),
)
}
@Test
fun domainErrorTokensMatchSpec14_5_3() {
assertEquals(
listOf(
"amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000",
"exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch",
),
wire(SplitError.entries.toTypedArray()),
)
assertEquals(listOf("amount_out_of_range", "no_accounts"), wire(AllocationError.entries.toTypedArray()))
assertEquals(
listOf(
"amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations",
"allocation_not_positive", "duplicate_account", "allocation_sum_mismatch",
),
wire(SettlementError.entries.toTypedArray()),
)
}
@Test
fun categoryIconsAreTheThirtyTokensOfSpec9_7InOrder() {
assertEquals(30, CategoryIcons.ALL.size)
assertEquals(30, CategoryIcons.ALL.toSet().size)
assertEquals("shopping_cart", CategoryIcons.ALL.first())
assertEquals("category", CategoryIcons.ALL.last())
// The ten default categories (AC-7.2) use icons from the set.
listOf("shopping_cart", "restaurant", "home", "bolt", "directions_car", "flight", "movie", "medical_services", "shopping_bag", "category")
.forEach { assertEquals(it, CategoryIcons.ALL.single { icon -> icon == it }) }
}
}