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