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