M12f: S-16 lost version read-only, S-22 versions from /health, settle start without a partner (D-109 to D-111)
- Rejection.lost: RejectedVersion (the pushed entity from sync_rejections.rejected_payload as a ledger row, account, payer, split, notes, deleted); S-16 "Öppna" shows it read-only in a dialog with "Visa nuvarande version" (S-8/S-10); without one it opens the current version as before. Ledger rows take a null onClick (read-only). - AboutState.schemaVersion; AboutRepositoryImpl calls E-1 /health once per collection when online; header fallback. S-22 shows "1.1.0 (schema 3)". - SettlementRepository.start/recompute return null without a session or partner; save is Invalid(payer); no checkNotNull/requireNotNull in :data. - :feature:home screenshot harness: one more frame and an idle looper before each capture; the committed sv_dark, 200 % and dialog goldens were one variant stale (light instead of dark, en instead of sv_200). - Tests: lost version (stale, refused deletion, unreadable payload), /health online and offline, settle without a partner, category reorder PATCHes, recurring toWire splits, §9.2 effect cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -82,9 +82,10 @@ private fun String.lowerFirst(): String = replaceFirstChar { it.lowercaseChar()
|
||||
* An expense as a segmented-group item (design-spec §3.5): category tile 40, description over the effect line, amount
|
||||
* over the date with the pending marker. At ≥ 1.5× font scale the amount column moves under the text. One node:
|
||||
* "ICA Maxi Lindhagen, Mat & hushåll, 1 247,60 kronor, 16 oktober, du lånade 623,80 kronor, väntar på synk".
|
||||
* [onClick] null draws the row read-only, without a ripple or the button role (S-16's lost version, D-109).
|
||||
*/
|
||||
@Composable
|
||||
fun ExpenseRow(item: SegmentedItemScope, row: LedgerRow.ExpenseRow, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
fun ExpenseRow(item: SegmentedItemScope, row: LedgerRow.ExpenseRow, onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val separator = stringResource(R.string.common_separator_comma)
|
||||
val spoken = listOfNotNull(
|
||||
row.description,
|
||||
@@ -110,7 +111,7 @@ fun ExpenseRow(item: SegmentedItemScope, row: LedgerRow.ExpenseRow, onClick: ()
|
||||
* One node "Anna betalade dig 800,00 kronor, 2 konton, 6 oktober".
|
||||
*/
|
||||
@Composable
|
||||
fun SettlementRow(item: SegmentedItemScope, row: LedgerRow.SettlementRow, typeLabel: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
fun SettlementRow(item: SegmentedItemScope, row: LedgerRow.SettlementRow, typeLabel: String, onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val household = LocalKvittHousehold.current
|
||||
val accounts = pluralStringResource(R.plurals.ledger_settlement_accounts, row.accountCount, row.accountCount)
|
||||
val title = settlementSentence(row.from, row.to, money(row.amountOre), household.isViewer(row.to.id))
|
||||
@@ -155,7 +156,7 @@ fun SettlementTile(size: Dp, modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
private fun LedgerItem(
|
||||
item: SegmentedItemScope,
|
||||
onClick: () -> Unit,
|
||||
onClick: (() -> Unit)?,
|
||||
spoken: String,
|
||||
modifier: Modifier,
|
||||
fill: androidx.compose.ui.graphics.Color,
|
||||
@@ -168,8 +169,11 @@ private fun LedgerItem(
|
||||
.fillMaxWidth()
|
||||
.clip(item.shape)
|
||||
.background(fill)
|
||||
.clickable(onClick = onClick)
|
||||
.clearAndSetSemantics { contentDescription = spoken; role = Role.Button }
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = spoken
|
||||
if (onClick != null) role = Role.Button
|
||||
}
|
||||
.heightIn(min = KvittTheme.spacing.ledgerRowMin)
|
||||
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
|
||||
verticalAlignment = if (large) Alignment.Top else Alignment.CenterVertically,
|
||||
|
||||
@@ -71,7 +71,7 @@ object FakeScenarios {
|
||||
clockSkew = false,
|
||||
syncRunning = false,
|
||||
update = UpdateState.None,
|
||||
about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null),
|
||||
about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null, null),
|
||||
online = true,
|
||||
today = "2026-10-17",
|
||||
now = "2026-10-17T06:00:00.000Z",
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@ import se.hagstedt.kvitt.model.money.SettlementError
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class FakeSettlementRepository(private val store: FakeStore) : SettlementRepository {
|
||||
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
|
||||
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? {
|
||||
val s = store.current
|
||||
val partner = requireNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
|
||||
val partner = s.partner ?: return null
|
||||
val existing = editing?.let { id -> s.settlements.firstOrNull { it.id == id } }
|
||||
if (existing != null) {
|
||||
return s.settleState(
|
||||
@@ -39,7 +39,7 @@ class FakeSettlementRepository(private val store: FakeStore) : SettlementReposit
|
||||
return s.settleState(SettleRequest(null, payer, amount, fixedAccount, null, s.today, ""))
|
||||
}
|
||||
|
||||
override suspend fun recompute(request: SettleRequest): SettleState = store.current.settleState(request)
|
||||
override suspend fun recompute(request: SettleRequest): SettleState? = store.current.settleState(request)
|
||||
|
||||
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
|
||||
val s = store.current
|
||||
|
||||
@@ -9,7 +9,10 @@ import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||
import se.hagstedt.kvitt.model.EntityType
|
||||
import se.hagstedt.kvitt.model.ExpenseEffect
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.model.LedgerRow
|
||||
import se.hagstedt.kvitt.model.RejectedVersion
|
||||
import se.hagstedt.kvitt.model.Rejection
|
||||
import se.hagstedt.kvitt.model.SettlementId
|
||||
import se.hagstedt.kvitt.model.Share
|
||||
@@ -119,8 +122,47 @@ object HomeScenarios {
|
||||
settlement(4, jacob, mapOf(huset.id to 308_450, japan.id to 135_000), "2026-10-17"),
|
||||
)
|
||||
|
||||
private fun categoryOf(key: DefaultCategoryKey) = FakeScenarios.defaultCategories.first { it.defaultKey == key }
|
||||
|
||||
/** A lost expense version (D-109): Jacob paid, split in halves by the account default unless [jacobBp] says otherwise. */
|
||||
private fun lostExpense(
|
||||
n: Int,
|
||||
key: DefaultCategoryKey,
|
||||
amountOre: Long,
|
||||
description: String,
|
||||
date: String,
|
||||
mode: SplitMode = SplitMode.DEFAULT,
|
||||
jacobBp: Int = 5_000,
|
||||
notes: String = "",
|
||||
deleted: Boolean = false,
|
||||
): RejectedVersion {
|
||||
val jacobShare = amountOre * jacobBp / 10_000
|
||||
val row = LedgerRow.ExpenseRow(ExpenseId(id("e", n)), date, description, categoryOf(key), amountOre, ExpenseEffect.Lent(amountOre - jacobShare), pending = false)
|
||||
val bp = if (mode == SplitMode.EXACT) null else jacobBp
|
||||
val shares = listOf(Share(jacob, jacobShare, bp), Share(anna, amountOre - jacobShare, bp?.let { 10_000 - it }))
|
||||
return RejectedVersion(row, vardag.name, FakeScenarios.owner, mode, shares, notes, deleted)
|
||||
}
|
||||
|
||||
/** Jacob's lost version of the Babette dinner: 1 240 kr, 60/40, with a note. */
|
||||
val lostBabette = lostExpense(12, DefaultCategoryKey.RESTAURANTS, 124_000, "Babette", "2026-10-16", mode = SplitMode.RATIO, jacobBp = 6_000, notes = "Pizza och vin med Lisa och Erik")
|
||||
|
||||
/** 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", FakeScenarios.partner)
|
||||
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z", FakeScenarios.partner, lostBabette)
|
||||
|
||||
/** A refused deletion (clock skew): the cinema tickets Jacob deleted on the phone. */
|
||||
val clockSkewRejection = Rejection(
|
||||
"m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z", null,
|
||||
lostExpense(15, DefaultCategoryKey.ENTERTAINMENT, 34_000, "Biobiljetter Rigoletto", "2026-10-15", deleted = true),
|
||||
)
|
||||
|
||||
/** A refused payment from Jacob to Anna, 500 kr in Vardag. */
|
||||
val paymentRejection = Rejection(
|
||||
"m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z", null,
|
||||
RejectedVersion(
|
||||
LedgerRow.SettlementRow(SettlementId(id("5", 9)), "2026-10-17", FakeScenarios.owner, FakeScenarios.partner, 50_000, 1, pending = false),
|
||||
null, null, null, emptyList(), "Betalning till Anna", deleted = false,
|
||||
),
|
||||
)
|
||||
|
||||
/** S-16 loaded: two changes waiting and one `stale` rejection. */
|
||||
val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection))
|
||||
@@ -180,10 +222,14 @@ 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", 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),
|
||||
Rejection(
|
||||
"m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z", null,
|
||||
lostExpense(13, DefaultCategoryKey.GROCERIES, 8_900, "Pressbyrån Odenplan", "2026-10-16"),
|
||||
),
|
||||
paymentRejection,
|
||||
// Its category is gone from the phone too, so there is no lost version to show; "Öppna" opens S-8.
|
||||
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z", null, null),
|
||||
clockSkewRejection,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+3
-2
@@ -116,7 +116,7 @@ object SettingsScenarios {
|
||||
devices = devices,
|
||||
invites = listOf(pendingDeviceInvite),
|
||||
lastSuccessAt = "2026-10-17T11:59:00.000Z",
|
||||
about = AboutState("1.1", 11, "https://icehack.asuscomm.com:8443", "1.1.0"),
|
||||
about = AboutState("1.1", 11, "https://icehack.asuscomm.com:8443", "1.1.0", 3),
|
||||
today = "2026-10-17",
|
||||
now = "2026-10-17T12:02:00.000Z",
|
||||
defaultAccountId = huset.id,
|
||||
@@ -188,7 +188,8 @@ object SettingsScenarios {
|
||||
},
|
||||
)
|
||||
|
||||
private fun FakeScenario.withServerVersion(version: String?): FakeScenario = copy(about = about.copy(serverVersion = version))
|
||||
/** Offline the server version is the last response's header (here none) and the schema version unknown (D-110). */
|
||||
private fun FakeScenario.withServerVersion(version: String?): FakeScenario = copy(about = about.copy(serverVersion = version, schemaVersion = null))
|
||||
|
||||
/** Every scenario of this file by name, for previews and tests. */
|
||||
val all: Map<String, FakeScenario> = mapOf(
|
||||
|
||||
@@ -50,7 +50,7 @@ class EmptyScenarioTest {
|
||||
assertNull(options.defaultAccountId)
|
||||
assertEquals(14, options.categories.size)
|
||||
assertTrue(options.frequentCategoryIds.isEmpty()) // UX-9: no active account, no entry
|
||||
val settle = fakes.settlements.start(null, null)
|
||||
val settle = requireNotNull(fakes.settlements.start(null, null))
|
||||
assertNull(settle.amountOre)
|
||||
assertTrue(settle.lines.isEmpty())
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amou
|
||||
data class SettlementDetailState(val id: SettlementId, val from: User, val to: User, val amountOre: Long, val date: String, val note: String, val allocations: List<Pair<Account, Long>>, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
|
||||
|
||||
interface SettlementRepository {
|
||||
suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState // defaults per AC-11.3 / AC-11.4
|
||||
suspend fun recompute(request: SettleRequest): SettleState // allocations == null: propose (§9.4); else validate and warn
|
||||
// v0.18, D-111: null when there is no paired session or the partner has not joined (S-9 is not offered then, AC-5.1).
|
||||
suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? // defaults per AC-11.3 / AC-11.4
|
||||
suspend fun recompute(request: SettleRequest): SettleState? // allocations == null: propose (§9.4); else validate and warn
|
||||
suspend fun save(request: SettleRequest): SaveResult<SettlementId>
|
||||
suspend fun delete(id: SettlementId)
|
||||
fun detail(id: SettlementId): Flow<SettlementDetailState?>
|
||||
|
||||
@@ -6,9 +6,19 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* [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.
|
||||
* null when the server had no row or the user is unknown. [lost]: the phone's version that was refused, read from
|
||||
* `sync_rejections.rejected_payload` and shown read-only on S-16 (v0.18, D-109); null when it cannot be shown (its
|
||||
* category or a user is not on the phone, or the payload is unreadable).
|
||||
*/
|
||||
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 Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String, val actor: User?, val lost: RejectedVersion?)
|
||||
|
||||
/**
|
||||
* The lost local version of a rejected change, as pushed (v0.18, D-109). [row] is the ledger row it would have been
|
||||
* (`pending` false). For an expense: [accountName] its account, [payer], [mode] and [shares] its split; for a settlement
|
||||
* those are null or empty and the row carries the people and the amount. [notes] is the expense's notes or the
|
||||
* settlement's note; [deleted] is true when the refused change was a deletion.
|
||||
*/
|
||||
data class RejectedVersion(val row: LedgerRow, val accountName: String?, val payer: User?, val mode: SplitMode?, val shares: List<Share>, val notes: String, val deleted: Boolean)
|
||||
|
||||
data class SyncStatusState(val summary: SyncSummary, val pending: List<LedgerRow>, val rejections: List<Rejection>)
|
||||
|
||||
@@ -45,7 +55,12 @@ interface UpdateRepository {
|
||||
suspend fun install()
|
||||
}
|
||||
|
||||
data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?)
|
||||
/**
|
||||
* [serverVersion] and [schemaVersion] come from E-1 `/health`, fetched when S-22 opens and online (v0.18, D-110);
|
||||
* offline or when it fails, [serverVersion] falls back to the `X-Kvitt-Server-Version` header of the last response and
|
||||
* [schemaVersion] is null.
|
||||
*/
|
||||
data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?, val schemaVersion: Int?)
|
||||
|
||||
interface AboutRepository {
|
||||
fun about(): Flow<AboutState>
|
||||
|
||||
Reference in New Issue
Block a user