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:
+1
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+7
-3
@@ -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(),
|
||||
|
||||
+16
-7
@@ -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
|
||||
}
|
||||
|
||||
+10
-1
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+9
-2
@@ -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()
|
||||
|
||||
+3
-3
@@ -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")
|
||||
|
||||
+3
-5
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user