M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

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 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
@@ -277,7 +277,7 @@ fun SyncPillSheet() {
SyncStatusPill(Samples.pendingSummary, online = false, onClick = {})
SyncStatusPill(Samples.rejectedSummary, online = true, onClick = {})
SyncStatusPill(Samples.clockSummary, online = true, onClick = {})
SyncStatusPill(Samples.failedSummary, online = true, onClick = {}, failed = true)
SyncStatusPill(Samples.failedSummary, online = true, onClick = {})
}
@Composable
@@ -94,12 +94,12 @@ object Samples {
val gotlandDetail = AccountDetailState(gotland, BalanceView(0, anna), emptyList(), deletable = false, users = listOf(jacob, anna))
val husetDetail = AccountDetailState(huset, BalanceView(-308_450, anna), emptyList(), deletable = false, users = listOf(jacob, anna))
val syncedSummary = SyncSummary("2026-10-17T11:59:00.000Z", pendingCount = 0, rejectedCount = 0, clockSkew = false, running = false)
val syncedSummary = SyncSummary("2026-10-17T11:59:00.000Z", pendingCount = 0, rejectedCount = 0, clockSkew = false, running = false, lastAttemptFailed = false)
val pendingSummary = syncedSummary.copy(pendingCount = 2)
val runningSummary = syncedSummary.copy(running = true)
val rejectedSummary = syncedSummary.copy(rejectedCount = 1)
val clockSummary = syncedSummary.copy(clockSkew = true)
val failedSummary = syncedSummary.copy(lastSuccessAt = "2026-10-17T09:30:00.000Z")
val failedSummary = syncedSummary.copy(lastSuccessAt = "2026-10-17T09:30:00.000Z", lastAttemptFailed = true)
val update = UpdateInfo(versionCode = 12, versionName = "1.2", sizeBytes = 8_400_000)
@@ -47,14 +47,14 @@ import kotlin.time.Instant
enum class SyncPillVariant { Synced, Running, Offline, Rejected, ClockWrong, Failed }
/**
* The pill's variant: the clock problem and refused changes win, then offline, a failed last attempt ([failed]: the
* repositories do not expose it yet, see the M6 report), running, else synced.
* The pill's variant: the clock problem and refused changes win, then offline, a failed last attempt
* (`SyncSummary.lastAttemptFailed`), running, else synced.
*/
fun syncPillVariant(summary: SyncSummary, online: Boolean, failed: Boolean = false): SyncPillVariant = when {
fun syncPillVariant(summary: SyncSummary, online: Boolean): SyncPillVariant = when {
summary.clockSkew -> SyncPillVariant.ClockWrong
summary.rejectedCount > 0 -> SyncPillVariant.Rejected
!online -> SyncPillVariant.Offline
failed -> SyncPillVariant.Failed
summary.lastAttemptFailed -> SyncPillVariant.Failed
summary.running || summary.lastSuccessAt == null -> SyncPillVariant.Running
else -> SyncPillVariant.Synced
}
@@ -75,10 +75,10 @@ fun relativeTime(rfc3339: String): String {
/** The visible text of the sync line, also used by S-13's "Synkstatus" subtitle. */
@Composable
@ReadOnlyComposable
fun syncLineText(summary: SyncSummary, online: Boolean, failed: Boolean = false): String {
fun syncLineText(summary: SyncSummary, online: Boolean): String {
val dot = stringResource(R.string.common_separator_dot)
val pending = if (summary.pendingCount > 0) pluralStringResource(R.plurals.sync_pending, summary.pendingCount, summary.pendingCount) else null
return when (syncPillVariant(summary, online, failed)) {
return when (syncPillVariant(summary, online)) {
SyncPillVariant.ClockWrong -> stringResource(R.string.sync_clock_wrong_short)
SyncPillVariant.Rejected -> pluralStringResource(R.plurals.sync_rejected, summary.rejectedCount, summary.rejectedCount)
SyncPillVariant.Offline -> listOfNotNull(stringResource(R.string.common_offline), pending).joinToString(dot)
@@ -93,11 +93,11 @@ fun syncLineText(summary: SyncSummary, online: Boolean, failed: Boolean = false)
* The caller refreshes it every minute by recomposing with the clock.
*/
@Composable
fun SyncStatusPill(summary: SyncSummary, online: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, failed: Boolean = false) {
fun SyncStatusPill(summary: SyncSummary, online: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val colors = KvittTheme.colors
val scheme = MaterialTheme.colorScheme
val variant = syncPillVariant(summary, online, failed)
val text = syncLineText(summary, online, failed)
val variant = syncPillVariant(summary, online)
val text = syncLineText(summary, online)
val (fill, icon, iconTint) = when (variant) {
SyncPillVariant.Synced -> Triple(scheme.surfaceContainerHigh, KvittIcon.CloudDone, colors.owed)
SyncPillVariant.Running -> Triple(scheme.surfaceContainerHigh, KvittIcon.Sync, scheme.onSurfaceVariant)
@@ -97,13 +97,13 @@ class ComponentLogicTest {
@Test
fun syncPillVariants() {
val synced = SyncSummary("2026-10-17T11:59:00.000Z", 0, 0, clockSkew = false, running = false)
val synced = SyncSummary("2026-10-17T11:59:00.000Z", 0, 0, clockSkew = false, running = false, lastAttemptFailed = false)
assertEquals(SyncPillVariant.Synced, syncPillVariant(synced, online = true))
assertEquals(SyncPillVariant.Running, syncPillVariant(synced.copy(running = true), online = true))
assertEquals(SyncPillVariant.Offline, syncPillVariant(synced.copy(pendingCount = 2), online = false))
assertEquals(SyncPillVariant.Rejected, syncPillVariant(synced.copy(rejectedCount = 1), online = false))
assertEquals(SyncPillVariant.ClockWrong, syncPillVariant(synced.copy(clockSkew = true, rejectedCount = 1), online = true))
assertEquals(SyncPillVariant.Failed, syncPillVariant(synced, online = true, failed = true))
assertEquals(SyncPillVariant.Failed, syncPillVariant(synced.copy(lastAttemptFailed = true), online = true))
}
@Test
@@ -9,25 +9,28 @@ import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.BalanceView
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.HomeState
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.LedgerMonth
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.Pairing
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncSummary
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.domain.BP_TOTAL
class FakeSessionRepository(private val store: FakeStore) : SessionRepository {
override val session: Flow<SessionState> = store.derive { it.session }
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> =
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> =
store.online { s ->
store.edit { it.copy(session = SessionState.Paired(s.me, s.partner, s.deviceId)) }
OnlineResult.Ok(s.me)
val kind = s.pairingInviteKind ?: if (s.me.role == Role.MEMBER) InviteKind.PARTNER else InviteKind.ADMIN_SETUP
OnlineResult.Ok(Pairing(s.me, kind))
}
override suspend fun acknowledgeRevoked() = store.edit { it.copy(session = SessionState.Unpaired) }
@@ -39,6 +42,7 @@ internal fun FakeScenario.syncSummary(): SyncSummary = SyncSummary(
rejectedCount = rejections.size,
clockSkew = clockSkew,
running = syncRunning,
lastAttemptFailed = lastAttemptFailed && !syncRunning,
)
internal fun UpdateState.info(): UpdateInfo? = when (this) {
@@ -9,6 +9,7 @@ import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SessionState
@@ -63,6 +64,8 @@ data class FakeSettlement(
* @property today the phone's local date (`YYYY-MM-DD`); [now] the phone's clock in the fixed RFC 3339 form.
* @property defaultAccountId the account last used on this phone (AC-8.6); [lastCategoryFor] the category last used
* per account on this phone.
* @property lastAttemptFailed the last sync cycle failed (`SyncSummary.lastAttemptFailed`, false while one runs).
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
*/
data class FakeScenario(
val me: User,
@@ -87,6 +90,8 @@ data class FakeScenario(
val now: String,
val defaultAccountId: AccountId?,
val lastCategoryFor: Map<AccountId, CategoryId>,
val lastAttemptFailed: Boolean = false,
val pairingInviteKind: InviteKind? = null,
) {
val users: List<User> get() = listOfNotNull(me, partner)
}
@@ -63,7 +63,7 @@ object FakeScenarios {
settlements = emptyList(),
templates = emptyList(),
devices = listOf(
Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, current = true, revoked = false),
Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, "1.0", current = true, revoked = false),
),
invites = emptyList(),
rejections = emptyList(),
@@ -26,12 +26,14 @@ import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.SyncStatusState
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
@@ -124,10 +126,11 @@ class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository
class FakeRecurringRepository(private val store: FakeStore) : RecurringRepository {
override fun all(): Flow<List<RecurringTemplate>> = store.derive { it.templates }
override suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId> = store.online { s ->
s.validateTemplate(t) ?: run {
val id = TemplateId(store.newId())
val created = t.copy(id = id, nextOccurrence = s.nextOccurrence(t))
override suspend fun create(t: NewRecurringTemplate): OnlineResult<TemplateId> = store.online { s ->
val id = TemplateId(store.newId())
val template = RecurringTemplate(id, t.draft, t.dayOfMonth, t.startPeriod, t.endPeriod, t.paused, nextOccurrence = null)
s.validateTemplate(template) ?: run {
val created = template.copy(nextOccurrence = s.nextOccurrence(template))
store.edit { it.copy(templates = it.templates + created) }
OnlineResult.Ok(id)
}
@@ -272,11 +275,17 @@ class FakeSyncRepository(private val store: FakeStore) : SyncRepository {
override suspend fun dismiss(mutationId: String) = store.edit { s -> s.copy(rejections = s.rejections.filter { it.mutationId != mutationId }) }
}
/** The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, AC-16.5). */
class FakeUpdateRepository(store: FakeStore) : UpdateRepository {
/**
* The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update,
* AC-16.5). `check` reports the scenario's state: `Available` as found, anything else as up to date.
*/
class FakeUpdateRepository(private val store: FakeStore) : UpdateRepository {
override val state: Flow<UpdateState> = store.derive { it.update }
override suspend fun check() = Unit
override suspend fun check(): UpdateCheck = when (val update = store.current.update) {
is UpdateState.Available -> UpdateCheck.Available(update.info)
else -> UpdateCheck.UpToDate
}
override suspend fun install() = Unit
}
@@ -13,6 +13,7 @@ import se.hagstedt.kvitt.model.SettleState
import se.hagstedt.kvitt.model.SettlementDetailState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SettlementRepository
import se.hagstedt.kvitt.model.UntouchedAccount
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.money.AccountPosition
import se.hagstedt.kvitt.model.money.Allocation
@@ -129,6 +130,14 @@ private fun FakeScenario.settleState(request: SettleRequest): SettleState {
val valid = amount != null && validateSettlement(
amount, payer.id.value, recipient.id.value, users.map { it.id.value }, lines.map { Allocation(it.account.id.value, it.amountOre) },
) == null
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note)
val untouched = if (fixed != null) {
emptyList()
} else {
accountsInOrder().mapNotNull { account ->
val net = balances.net(account.id, request.payer)
UntouchedAccount(account, net).takeIf { net > 0 && lines.none { it.account.id == account.id } }
}
}
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note, untouched)
}
@@ -120,7 +120,7 @@ object HomeScenarios {
)
/** A `stale` rejection of Anna's newer Babette expense (S-16 loaded). */
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z")
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z", FakeScenarios.partner)
/** S-16 loaded: two changes waiting and one `stale` rejection. */
val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection))
@@ -180,10 +180,10 @@ object HomeScenarios {
val allRejections: FakeScenario = pending.copy(
rejections = listOf(
staleRejection,
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z"),
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z"),
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z"),
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z"),
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z", null),
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z", null),
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z", null),
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z", null),
),
)
@@ -11,6 +11,7 @@ import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.Pairing
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
@@ -46,7 +47,7 @@ class ScriptedSessionRepository(private val store: FakeStore, var pairOutcome: C
override val session: Flow<SessionState> = delegate.session
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> {
pairCalls += PairCall(serverUrl, code, displayName, deviceName)
return store.scripted(pairOutcome) { delegate.pair(serverUrl, code, displayName, deviceName) }
}
@@ -15,9 +15,11 @@ import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
@@ -65,7 +67,7 @@ class ScriptedSettingsRepositories(scenario: FakeScenario) {
}
val recurring: RecurringRepository = object : RecurringRepository by fakes.recurring {
override suspend fun create(t: RecurringTemplate) = script.run { fakes.recurring.create(t) }
override suspend fun create(t: NewRecurringTemplate) = script.run { fakes.recurring.create(t) }
override suspend fun update(t: RecurringTemplate) = script.run { fakes.recurring.update(t) }
override suspend fun delete(id: TemplateId) = script.run { fakes.recurring.delete(id) }
}
@@ -89,12 +91,17 @@ class ScriptedSettingsRepositories(scenario: FakeScenario) {
/** `check` of the fake leaves the state unchanged; [afterCheck] is the state a check finds (S-22 result states). */
var afterCheck: UpdateState? = null
/** A check that cannot reach the server: `Failed`, the state unchanged. */
var checkFails: Boolean = false
val update: UpdateRepository = object : UpdateRepository {
override val state = fakes.update.state
override suspend fun check() {
override suspend fun check(): UpdateCheck {
if (script.hang) awaitCancellation()
if (checkFails) return UpdateCheck.Failed
afterCheck?.let { found -> fakes.store.edit { it.copy(update = found) } }
return fakes.update.check()
}
override suspend fun install() = fakes.update.install()
@@ -97,9 +97,9 @@ object SettingsScenarios {
)
private val devices = listOf(
Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, current = true, revoked = false),
Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, current = false, revoked = false),
Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, current = false, revoked = true),
Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, "1.1", current = true, revoked = false),
Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, "1.1", current = false, revoked = false),
Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, "1.0", current = false, revoked = true),
)
private val pendingDeviceInvite = Invite(InviteId("01920000-0000-7000-8000-0000000000aa"), InviteKind.DEVICE, anna.id, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z")
@@ -13,10 +13,9 @@ import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
/** UX-9 in the fakes: `frequentCategoryIds` and `deletable` follow the same rule as `:data`. */
class FakeEditorCategoriesTest {
@@ -72,10 +71,9 @@ class FakeEditorCategoriesTest {
assertFalse(fakes.categories.deletable(pant).first())
fakes.expenses.delete(e)
assertTrue(fakes.categories.deletable(pant).first())
val template = RecurringTemplate(
TemplateId("t"),
val template = NewRecurringTemplate(
ExpenseDraft(null, vardag, pant, 10_000, "Pant", "", FakeScenarios.empty.today, FakeScenarios.owner.id, SplitMode.DEFAULT, null, null, null),
1, "2026-10", null, paused = false, nextOccurrence = null,
1, "2026-10", null, paused = false,
)
val created = (fakes.recurring.create(template) as OnlineResult.Ok).value
assertFalse(fakes.categories.deletable(pant).first())
@@ -9,7 +9,8 @@ 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)
/** [lastAttemptFailed]: the last finished sync cycle did not succeed (network, server error, refused); false while one runs. */
data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean, val lastAttemptFailed: 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?)
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.model
// Push payload v1 as the project's own record (SPEC §6.2, §14.6); `:adapter:fcm` maps FCM data messages into it.
/**
* One push of payload v1. [type] is kept as the open string the server sent so `:app` can treat an unknown type as
* "sync only" (§14.6 phone step 4). Empty [entityType] and [entityId] for `sync_batch`. No personal content ever reaches
* this record (AC-13.8).
*/
data class PushEvent(
val type: String,
val entityType: String,
val entityId: String,
val actorUserId: String,
val count: Int,
)
@@ -11,8 +11,11 @@ sealed interface SessionState {
data object Unrecognised : SessionState // 401 unauthorized: pair again, data kept (§14.4.8)
}
/** A successful pairing: the paired user and the kind of the accepted invite (E-3); S-2 follows only [InviteKind.PARTNER] (AC-2.10). */
data class Pairing(val user: User, val inviteKind: InviteKind)
interface SessionRepository {
val session: Flow<SessionState>
suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User>
suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing>
suspend fun acknowledgeRevoked() // wipes local data (AC-3.3) and moves to Unpaired
}
@@ -29,14 +29,18 @@ interface CategoryRepository {
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
/** A template to create: the repository (the server, E-28) mints the id; the next occurrence comes back through sync. */
data class NewRecurringTemplate(val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean)
interface RecurringRepository {
fun all(): Flow<List<RecurringTemplate>>
suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId>
suspend fun create(t: NewRecurringTemplate): 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)
/** [appVersionName]: the `versionName` the device last reported ("1.1"), null until it has (schema 3). */
data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val appVersionName: String?, val current: Boolean, val revoked: Boolean)
interface DeviceRepository {
suspend fun list(): OnlineResult<List<Device>>
@@ -6,7 +6,14 @@ import kotlinx.coroutines.flow.Flow
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)
/** An account the settlement leaves as it is although the payer is owed there ([payerNetOre] > 0, §9.4 info line). */
data class UntouchedAccount(val account: Account, val payerNetOre: Long)
/**
* [untouched]: accounts not in [lines] where the payer is owed, nets without the settlement being edited (AC-11.6),
* in `AccountRepository.all()` order; empty for a per-account settle-up (D-074).
*/
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, val untouched: List<UntouchedAccount>)
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)
@@ -4,7 +4,11 @@ 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)
/**
* [actor]: who made the server's current version (its `updated_by_user_id`, the "<actor>" of S-16's stale copy, §14.4.6);
* null when the server had no row or the user is unknown.
*/
data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String, val actor: User?)
data class SyncStatusState(val summary: SyncSummary, val pending: List<LedgerRow>, val rejections: List<Rejection>)
@@ -24,9 +28,20 @@ sealed interface UpdateState {
data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
}
/** The outcome of [UpdateRepository.check] (S-22, AC-16.6). */
sealed interface UpdateCheck {
/** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */
data object UpToDate : UpdateCheck
data class Available(val info: UpdateInfo) : UpdateCheck
/** The server could not be reached or answered with an error; [UpdateRepository.state] is unchanged. */
data object Failed : UpdateCheck
}
interface UpdateRepository {
val state: Flow<UpdateState>
suspend fun check()
suspend fun check(): UpdateCheck
suspend fun install()
}