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,191 @@
package se.hagstedt.kvitt.data.db
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.Index
import androidx.room3.PrimaryKey
// Room entities of SPEC §14.2. Table and column names are the server's (§14.1); no foreign keys (sync pages may
// deliver a child before its parent). Timestamps are fixed-form RFC 3339 Strings, dates `YYYY-MM-DD`, money Long, bp Int.
// `local_updated_at` is epoch milliseconds on the phone clock, set on every write and never sent.
// 14.2.1 Mirrored tables
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "role") val role: String,
@ColumnInfo(name = "display_name") val displayName: String,
@ColumnInfo(name = "picture_version") val pictureVersion: Int?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "accounts", indices = [Index("created_at")])
data class AccountEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "archived_at") val archivedAt: String?,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "account_default_shares", primaryKeys = ["account_id", "user_id"])
data class AccountDefaultShareEntity(
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "share_bp") val shareBp: Int,
)
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "default_key") val defaultKey: String?,
@ColumnInfo(name = "name") val name: String?,
@ColumnInfo(name = "icon") val icon: String,
@ColumnInfo(name = "color") val color: String,
@ColumnInfo(name = "sort_order") val sortOrder: Int,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "archived_at") val archivedAt: String?,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "recurring_templates", indices = [Index("account_id")])
data class RecurringTemplateEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "category_id") val categoryId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "notes") val notes: String,
@ColumnInfo(name = "payer_user_id") val payerUserId: String,
@ColumnInfo(name = "split_mode") val splitMode: String,
@ColumnInfo(name = "day_of_month") val dayOfMonth: Int,
@ColumnInfo(name = "start_period") val startPeriod: String,
@ColumnInfo(name = "end_period") val endPeriod: String?,
@ColumnInfo(name = "paused") val paused: Boolean,
@ColumnInfo(name = "last_generated_period") val lastGeneratedPeriod: String?,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String,
@ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "next_occurrence_date") val nextOccurrenceDate: String?,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "recurring_template_splits", primaryKeys = ["template_id", "user_id"])
data class RecurringTemplateSplitEntity(
@ColumnInfo(name = "template_id") val templateId: String,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "share_bp") val shareBp: Int?,
@ColumnInfo(name = "share_ore") val shareOre: Long?,
)
@Entity(tableName = "expenses", indices = [Index("account_id"), Index("expense_date"), Index("pending")])
data class ExpenseEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "category_id") val categoryId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "notes") val notes: String,
@ColumnInfo(name = "expense_date") val expenseDate: String,
@ColumnInfo(name = "payer_user_id") val payerUserId: String,
@ColumnInfo(name = "split_mode") val splitMode: String,
@ColumnInfo(name = "recurring_template_id") val recurringTemplateId: String?,
@ColumnInfo(name = "recurring_period") val recurringPeriod: String?,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String?,
@ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long?,
@ColumnInfo(name = "pending") val pending: Boolean,
@ColumnInfo(name = "pending_mutation_id") val pendingMutationId: String?,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "expense_splits", primaryKeys = ["expense_id", "user_id"])
data class ExpenseSplitEntity(
@ColumnInfo(name = "expense_id") val expenseId: String,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "share_ore") val shareOre: Long,
@ColumnInfo(name = "share_bp") val shareBp: Int?,
)
@Entity(tableName = "settlements", indices = [Index("settlement_date"), Index("pending")])
data class SettlementEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "from_user_id") val fromUserId: String,
@ColumnInfo(name = "to_user_id") val toUserId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
@ColumnInfo(name = "settlement_date") val settlementDate: String,
@ColumnInfo(name = "note") val note: String,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String?,
@ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long?,
@ColumnInfo(name = "pending") val pending: Boolean,
@ColumnInfo(name = "pending_mutation_id") val pendingMutationId: String?,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "settlement_allocations", primaryKeys = ["settlement_id", "account_id"], indices = [Index("account_id")])
data class SettlementAllocationEntity(
@ColumnInfo(name = "settlement_id") val settlementId: String,
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
)
@Entity(tableName = "audit_log", indices = [Index("entity_type", "entity_id")])
data class AuditEntryEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "entity_type") val entityType: String,
@ColumnInfo(name = "entity_id") val entityId: String,
@ColumnInfo(name = "action") val action: String,
@ColumnInfo(name = "source") val source: String,
@ColumnInfo(name = "actor_user_id") val actorUserId: String,
@ColumnInfo(name = "client_at") val clientAt: String,
@ColumnInfo(name = "server_at") val serverAt: String,
@ColumnInfo(name = "changes") val changes: String,
@ColumnInfo(name = "server_version") val serverVersion: Long,
)
// 14.2.2 Local-only tables
@Entity(tableName = "sync_state")
data class SyncStateEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "cursor") val cursor: Long,
@ColumnInfo(name = "epoch") val epoch: String?,
@ColumnInfo(name = "last_success_at") val lastSuccessAt: String?,
@ColumnInfo(name = "last_attempt_at") val lastAttemptAt: String?,
@ColumnInfo(name = "last_error_code") val lastErrorCode: String?,
@ColumnInfo(name = "clock_skew") val clockSkew: Boolean,
)
@Entity(tableName = "sync_rejections")
data class SyncRejectionEntity(
@PrimaryKey @ColumnInfo(name = "mutation_id") val mutationId: String,
@ColumnInfo(name = "entity_type") val entityType: String,
@ColumnInfo(name = "entity_id") val entityId: String,
@ColumnInfo(name = "reason") val reason: String,
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "rejected_payload") val rejectedPayload: String,
@ColumnInfo(name = "server_current") val serverCurrent: String?,
@ColumnInfo(name = "rejected_at") val rejectedAt: String,
@ColumnInfo(name = "dismissed") val dismissed: Boolean,
)
@@ -0,0 +1,33 @@
package se.hagstedt.kvitt.data.db
import androidx.room3.Database
import androidx.room3.RoomDatabase
/**
* The phone's database (SPEC §14.2): file `kvitt.db`, version 1, schema exported to `android/data/schemas/`.
* DAOs, the builder and the SQLite driver are added by M5. Every later version adds a migration and its test.
*/
@Database(
entities = [
UserEntity::class,
AccountEntity::class,
AccountDefaultShareEntity::class,
CategoryEntity::class,
RecurringTemplateEntity::class,
RecurringTemplateSplitEntity::class,
ExpenseEntity::class,
ExpenseSplitEntity::class,
SettlementEntity::class,
SettlementAllocationEntity::class,
AuditEntryEntity::class,
SyncStateEntity::class,
SyncRejectionEntity::class,
],
version = 1,
exportSchema = true,
)
abstract class KvittDatabase : RoomDatabase() {
companion object {
const val FILE_NAME = "kvitt.db"
}
}