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:
2026-10-18 22:32:24 +02:00
parent 7fb86f5524
commit dc7a296d70
90 changed files with 550 additions and 70 deletions
@@ -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",
@@ -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,
),
)
@@ -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>
@@ -55,6 +55,10 @@ class KvittApi(
suspend fun acceptInvite(baseUrl: String, request: AcceptInviteRequest): ApiResult<AcceptInviteResponse> =
exchange(ApiTarget(baseUrl, null), HttpMethod.Post, "/invites/accept", authenticated = false) { jsonBody(request) }
// E-1: unauthenticated, to the paired server (S-22, D-110).
suspend fun health(): ApiResult<HealthDto> =
withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/health", authenticated = false) }
// E-5, E-6: unauthenticated.
suspend fun apkLatest(): ApiResult<ApkLatestDto> =
withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/apk/latest", authenticated = false) }
@@ -275,8 +275,8 @@ object DataModule {
SyncRepositoryImpl(local, db, summaries, engine, work)
@Provides @Singleton
fun about(credentials: CredentialStore, session: SessionManager, build: AppBuildInfo): AboutRepositoryImpl =
AboutRepositoryImpl(credentials, session.serverVersion, build.versionName, build.versionCode, DEFAULT_SERVER_URL)
fun about(credentials: CredentialStore, session: SessionManager, api: KvittApi, gate: OnlineGate, build: AppBuildInfo): AboutRepositoryImpl =
AboutRepositoryImpl(credentials, session.serverVersion, api, gate, build.versionName, build.versionCode, DEFAULT_SERVER_URL)
}
/** The `:core:model` interfaces bound to `:data`. Not installed automatically: `:app` includes it in its release graph. */
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -14,12 +15,15 @@ import se.hagstedt.kvitt.data.api.CreateInviteRequest
import se.hagstedt.kvitt.data.api.CreateTemplateRequest
import se.hagstedt.kvitt.data.api.DeviceDto
import se.hagstedt.kvitt.data.api.ErrorCodes
import se.hagstedt.kvitt.data.api.ExpensePushEntity
import se.hagstedt.kvitt.data.api.InviteDto
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.Patch
import se.hagstedt.kvitt.data.api.PatchCategoryRequest
import se.hagstedt.kvitt.data.api.PatchMeRequest
import se.hagstedt.kvitt.data.api.PatchTemplateRequest
import se.hagstedt.kvitt.data.api.SettlementPushEntity
import se.hagstedt.kvitt.data.api.TemplateSplitDto
import se.hagstedt.kvitt.data.auth.CredentialStore
import se.hagstedt.kvitt.data.connectivity.OnlineGate
@@ -43,6 +47,7 @@ import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
@@ -53,7 +58,10 @@ import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.SyncRepository
@@ -185,7 +193,7 @@ class RecurringRepositoryImpl(
}
/** E-28 body from the model; splits per §9.1: none for default, two bp for ratio and full, two öre for exact. */
private fun NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
internal fun NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
val accountId = draft.accountId ?: return null
val categoryId = draft.categoryId ?: return null
val amount = draft.amountOre ?: return null
@@ -313,6 +321,7 @@ class SyncRepositoryImpl(
* A rejection for S-16. `summary` is the rejected entity's own text (expense description; settlement note, or its
* amount in öre when the note is empty); the screen adds the explanation for the reason (§14.4.6). `actor` is the
* `updated_by_user_id` of the server's `current` row stored with the rejection (§14.4.3), resolved to a household user.
* `lost` is the pushed entity itself (`rejected_payload`), for the read-only view of S-16 (D-109).
*/
internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
val type = EntityType.entries.firstOrNull { it.name.lowercase() == entityType } ?: return null
@@ -323,7 +332,42 @@ internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
EntityType.EXPENSE -> payload?.get("description")?.jsonPrimitive?.contentOrNull
EntityType.SETTLEMENT -> payload?.get("note")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: payload?.get("amount_ore")?.jsonPrimitive?.contentOrNull
}
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor)
val lost = payload?.let { s.rejectedVersion(type, it) }
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor, lost)
}
/** The pushed entity as the row it would have been; null when it does not decode or its category or users are unknown. */
private fun Snapshot.rejectedVersion(type: EntityType, payload: JsonObject): RejectedVersion? = try {
when (type) {
EntityType.EXPENSE -> {
val e = KvittJson.decodeFromJsonElement(ExpensePushEntity.serializer(), payload)
val viewer = meId
val category = category(e.categoryId)
if (viewer == null || category == null) {
null
} else {
val splits = e.splits.map { it.userId to it.shareOre }
val row = LedgerRow.ExpenseRow(ExpenseId(e.id), e.expenseDate, e.description, category, e.amountOre, effectOf(e.payerUserId, splits, viewer), pending = false)
val shares = e.splits.sortedBy { it.userId }.map { Share(UserId(it.userId), it.shareOre, it.shareBp) }
RejectedVersion(row, account(e.accountId)?.name, user(e.payerUserId), splitModeOf(e.splitMode), shares, e.notes, e.deletedAt != null)
}
}
EntityType.SETTLEMENT -> {
val st = KvittJson.decodeFromJsonElement(SettlementPushEntity.serializer(), payload)
val from = user(st.fromUserId)
val to = user(st.toUserId)
if (from == null || to == null) {
null
} else {
val row = LedgerRow.SettlementRow(SettlementId(st.id), st.settlementDate, from, to, st.amountOre, st.allocations.size, pending = false)
RejectedVersion(row, null, null, null, emptyList(), st.note, st.deletedAt != null)
}
}
}
} catch (e: kotlinx.serialization.SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
private fun jsonObjectOrNull(text: String): JsonObject? = try {
@@ -332,15 +376,27 @@ private fun jsonObjectOrNull(text: String): JsonObject? = try {
null
}
/** S-22 (FR-16 versions, the paired server and the server's version header). */
/**
* S-22 (FR-16 versions, the paired server). Each collection (S-22 opening) asks E-1 `/health` once when online, for
* `server_version` and `schema_version` (§13.3, D-110); until it answers, offline or when it fails, the server version
* is the `X-Kvitt-Server-Version` header of the last response and the schema version is null.
*/
class AboutRepositoryImpl(
private val credentials: CredentialStore,
private val serverVersion: Flow<String?>,
private val api: KvittApi,
private val gate: OnlineGate,
private val versionName: String,
private val versionCode: Int,
private val defaultServerUrl: String,
) : AboutRepository {
override fun about(): Flow<AboutState> = combine(credentials.record, serverVersion) { record, server ->
AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, server)
}.distinctUntilChanged()
override fun about(): Flow<AboutState> {
val health = flow {
emit(null)
emit((gate.run({ api.health() }) { it } as? OnlineResult.Ok)?.value)
}
return combine(credentials.record, serverVersion, health) { record, header, h ->
AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, h?.serverVersion ?: header, h?.schemaVersion)
}.distinctUntilChanged()
}
}
@@ -44,10 +44,11 @@ class SettlementRepositoryImpl(
) : SettlementRepository {
private val timelines = Timelines(db, local)
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
/** Null without a paired session or before the partner has joined: S-9 is not offered then (AC-5.1, D-111). */
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? {
val s = local.snapshot.first()
val me = checkNotNull(s.me) { "Settle up needs a paired session" }
val partner = checkNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
val me = s.me ?: return null
val partner = s.partner ?: return null
val existing = editing?.let { s.settlement(it.value) }?.takeIf { !it.deleted }
if (existing != null) {
return s.settleState(
@@ -61,11 +62,12 @@ class SettlementRepositoryImpl(
return s.settleState(SettleRequest(null, payer, myNet.absoluteValue.takeIf { it > 0 }, fixedAccount, null, clock.today(), ""))
}
override suspend fun recompute(request: SettleRequest): SettleState = local.snapshot.first().settleState(request)
override suspend fun recompute(request: SettleRequest): SettleState? = local.snapshot.first().settleState(request)
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
val s = local.snapshot.first()
val state = s.settleState(request)
// D-111: an unknown payer or no partner yet cannot be saved; the screen is not offered then (AC-5.1).
val state = s.settleState(request) ?: return SaveResult.Invalid(listOf(FieldError("payer", ExpenseRepositoryImpl.VALIDATION_FAILED)))
val errors = mutableListOf<FieldError>()
val amount = request.amountOre
if (amount == null) {
@@ -145,11 +147,12 @@ private fun SettleState.allocations(): List<Allocation> = lines.map { Allocation
/**
* §9.4 for a request: with `allocations == null` the proposal of `proposeAllocation`, otherwise the given rows; a
* per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6).
* per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6). Null when
* the payer is not a household user or there is no recipient yet (D-111).
*/
internal fun Snapshot.settleState(request: SettleRequest): SettleState {
val payer = requireNotNull(user(request.payer.value)) { "unknown payer" }
val recipient = requireNotNull(household.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" }
internal fun Snapshot.settleState(request: SettleRequest): SettleState? {
val payer = user(request.payer.value) ?: return null
val recipient = household.firstOrNull { it.id != request.payer } ?: return null
val balances = balances(excludingSettlement = request.editing?.value)
val positions = accounts.map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id.value, request.payer)) }
val amount = request.amountOre
@@ -121,15 +121,7 @@ data class Snapshot(
)
/** §9.2 from the viewer's side. */
fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect {
val payer = e.row.payerUserId
val nonPayerShare = e.splits.firstOrNull { it.userId != payer }?.shareOre ?: 0L
return when {
nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare)
else -> ExpenseEffect.Borrowed(e.splits.firstOrNull { it.userId == viewer.value }?.shareOre ?: 0L)
}
}
fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect = effectOf(e.row.payerUserId, e.splits.map { it.userId to it.shareOre }, viewer)
/** A ledger row, or null while its category has not arrived (no foreign keys, §14.2). */
fun expenseRow(e: ExpenseRecord): LedgerRow.ExpenseRow? {
@@ -147,6 +139,16 @@ data class Snapshot(
}
}
/** §9.2 for a payer and the (user id, share in öre) pairs of an expense, from [viewer]'s side. */
fun effectOf(payer: String, splits: List<Pair<String, Long>>, viewer: UserId): ExpenseEffect {
val nonPayerShare = splits.firstOrNull { it.first != payer }?.second ?: 0L
return when {
nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare)
else -> ExpenseEffect.Borrowed(splits.firstOrNull { it.first == viewer.value }?.second ?: 0L)
}
}
/** AC-8.10: date descending, then `created_at` descending. */
val ledgerOrder: Comparator<Pair<String, String>> = compareByDescending<Pair<String, String>> { it.first }.thenByDescending { it.second }
@@ -34,6 +34,7 @@ import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.SettlementDto
import se.hagstedt.kvitt.data.api.ShareBpDto
import se.hagstedt.kvitt.data.api.SplitDto
import se.hagstedt.kvitt.data.api.TemplateSplitDto
import se.hagstedt.kvitt.data.draft
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
@@ -44,15 +45,18 @@ import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseEffect
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
@@ -206,7 +210,7 @@ class RepositoriesTest {
fun settleStart_theUserWhoOwesPays_amountIsTheBalance() = runSync {
h.addExpense("ICA", amountOre = 1001)
h.local.snapshot.first { it.liveExpenses.isNotEmpty() }
val state = h.settlementRepo().start(null, null)
val state = requireNotNull(h.settlementRepo().start(null, null))
assertEquals(PARTNER, state.payer.id.value)
assertEquals(500L, state.amountOre)
assertEquals(listOf(500L), state.lines.map { it.amountOre })
@@ -216,6 +220,25 @@ class RepositoriesTest {
assertEquals(0L, home().home().first { it.sync.pendingCount == 2 }.global?.netOre)
}
@Test
fun settleStart_withoutAPartner_isNull_andSaveIsInvalid_neverAThrow() = runSync {
val alone = Harness()
try {
alone.server.addUser(ME, "admin", "Jacob")
alone.server.addCategory(OTHER, "other", null)
alone.pair()
alone.engine.sync()
alone.local.snapshot.first { it.me != null }
assertNull("D-111: no partner yet (AC-5.1)", alone.settlementRepo().start(null, null))
val request = se.hagstedt.kvitt.model.SettleRequest(null, UserId(ME), 100, null, null, "2026-10-17", "")
assertNull(alone.settlementRepo().recompute(request))
val saved = alone.settlementRepo().save(request)
assertEquals(SaveResult.Invalid(listOf(FieldError("payer", "validation_failed"))), saved)
} finally {
alone.close()
}
}
@Test
fun settleState_listsTheAccountsTheSettlementLeavesUntouched() = runSync {
h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-09-01T00:00:00.000Z")
@@ -229,14 +252,14 @@ class RepositoriesTest {
)
h.engine.sync()
h.local.snapshot.first { it.liveExpenses.size == 2 }
val state = h.settlementRepo().start(null, null)
val state = requireNotNull(h.settlementRepo().start(null, null))
assertEquals(PARTNER, state.payer.id.value)
assertEquals(listOf(ACCOUNT), state.lines.map { it.account.id.value })
assertEquals(listOf(OLDER to 500L), state.untouched.map { it.account.id.value to it.payerNetOre })
// Rows naming Hemma touch it; a per-account settle-up lists nothing.
val both = h.settlementRepo().recompute(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, 501, null, mapOf(AccountId(ACCOUNT) to 1L, AccountId(OLDER) to 500L), state.date, ""))
val both = requireNotNull(h.settlementRepo().recompute(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, 501, null, mapOf(AccountId(ACCOUNT) to 1L, AccountId(OLDER) to 500L), state.date, "")))
assertTrue(both.untouched.isEmpty())
assertTrue(h.settlementRepo().start(null, AccountId(ACCOUNT)).untouched.isEmpty())
assertTrue(requireNotNull(h.settlementRepo().start(null, AccountId(ACCOUNT))).untouched.isEmpty())
}
@Test
@@ -305,6 +328,148 @@ class RepositoriesTest {
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
assertEquals(SyncRejectionReason.STALE, rejection.reason)
assertEquals(PARTNER, rejection.actor?.id?.value)
// D-109: the phone's lost version, from rejected_payload, not the server's winning row.
val lost = requireNotNull(rejection.lost)
val row = lost.row as LedgerRow.ExpenseRow
assertEquals("ICA", row.description)
assertEquals(41200L, row.amountOre)
assertEquals(GROCERIES, row.category.id.value)
assertEquals(ExpenseEffect.Lent(20600), row.effect)
assertFalse(row.pending)
assertEquals("Vardag", lost.accountName)
assertEquals(ME, lost.payer?.id?.value)
assertEquals(SplitMode.DEFAULT, lost.mode)
assertEquals(listOf(5000, 5000), lost.shares.map { it.shareBp })
assertFalse(lost.deleted)
}
@Test
fun syncStatus_aRefusedDeletionShowsTheDeletedLostVersion() = runSync {
val id = h.addExpense("ICA")
h.expenseRepo().delete(ExpenseId(id))
h.server.pushOverride = { request ->
val m = request.mutations.single()
se.hagstedt.kvitt.data.api.PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "validation_failed", null)))
}
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
repo.syncNow()
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
assertTrue(rejection.lost?.deleted == true)
assertEquals("ICA", (rejection.lost?.row as LedgerRow.ExpenseRow).description)
}
@Test
fun syncStatus_anUnreadablePayloadHasNoLostVersion() = runSync {
h.db.rejections().insertOrIgnore(
se.hagstedt.kvitt.data.db.SyncRejectionEntity("m-x", "expense", "e-x", "stale", "", "not json", null, "2026-10-17T10:00:00.000Z", false),
)
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
assertNull(rejection.lost)
assertEquals("", rejection.summary)
}
@Test
fun about_readsServerAndSchemaVersionFromHealth_whenOnline() = runSync {
h.server.restHandler = { _, path ->
if (path == "/health") HttpStatusCode.OK to """{"status":"ok","server_version":"1.2.0","schema_version":3,"db_ok":true}""" else null
}
val repo = AboutRepositoryImpl(h.credentials, kotlinx.coroutines.flow.MutableStateFlow("1.1.0"), h.api, h.gate, "1.1", 11, "https://default.test")
val state = repo.about().first { it.schemaVersion != null }
assertEquals("1.2.0", state.serverVersion)
assertEquals(3, state.schemaVersion)
assertEquals(Harness.BASE_URL, state.serverUrl)
val health = h.requests.single { it.url.encodedPath == "/health" }
assertNull("E-1 is unauthenticated", health.headers["Authorization"])
}
@Test
fun about_offlineOrFailingHealth_fallsBackToTheHeaderVersion() = runSync {
val header = kotlinx.coroutines.flow.MutableStateFlow<String?>("1.1.0")
h.monitor.state.value = false
val repo = AboutRepositoryImpl(h.credentials, header, h.api, h.gate, "1.1", 11, "https://default.test")
val offline = repo.about().first()
assertEquals("1.1.0", offline.serverVersion)
assertNull(offline.schemaVersion)
assertNull(kotlinx.coroutines.withTimeoutOrNull(300) { repo.about().first { it.schemaVersion != null } })
assertTrue("no request offline", h.requests.none { it.url.encodedPath == "/health" })
h.monitor.state.value = true
h.server.restHandler = { _, path -> if (path == "/health") HttpStatusCode.ServiceUnavailable to """{"code":"internal","message":"x","field":null}""" else null }
assertNull(kotlinx.coroutines.withTimeoutOrNull(300) { repo.about().first { it.schemaVersion != null } })
assertEquals("1.1.0", repo.about().first().serverVersion)
}
@Test
fun categoryReorder_sendsOnePatchPerChangedSortOrder() = runSync {
val patched = mutableListOf<Pair<String, Int?>>()
h.server.restHandler = { request, path ->
if (request.method == HttpMethod.Patch && path.startsWith("/categories/")) {
val id = path.removePrefix("/categories/")
val text = when (val content = request.body) {
is io.ktor.http.content.TextContent -> content.text
is io.ktor.http.content.OutgoingContent.ByteArrayContent -> String(content.bytes())
else -> throw AssertionError("unexpected body ${content::class.java.name}")
}
val body = KvittJson.parseToJsonElement(text)
val order = (body as kotlinx.serialization.json.JsonObject)["sort_order"]?.toString()?.toInt()
patched += id to order
val updated = h.server.categories.getValue(id).copy(sortOrder = order ?: 0, serverVersion = h.server.next())
h.server.categories[id] = updated
HttpStatusCode.OK to KvittJson.encodeToString(se.hagstedt.kvitt.data.api.CategoryDto.serializer(), updated)
} else {
null
}
}
val repo = CategoryRepositoryImpl(h.local, h.api, h.gate, h.db, h.clock)
assertEquals(OnlineResult.Ok(Unit), repo.reorder(listOf(CategoryId(GROCERIES), CategoryId(OTHER))))
assertTrue("unchanged order: no request", patched.isEmpty())
assertEquals(OnlineResult.Ok(Unit), repo.reorder(listOf(CategoryId(OTHER), CategoryId(GROCERIES))))
assertEquals(listOf(OTHER to 10, GROCERIES to 20), patched)
assertEquals(listOf(OTHER, GROCERIES), repo.all().first { it.first().id.value == OTHER }.map { it.id.value })
}
@Test
fun recurringToWire_splitsPerMode() {
val me = UserId(ME)
val partner = UserId(PARTNER)
fun template(mode: SplitMode, ratio: Map<UserId, Int>? = null, exact: Map<UserId, Long>? = null, fullOn: UserId? = null) = NewRecurringTemplate(
se.hagstedt.kvitt.model.ExpenseDraft(null, AccountId(ACCOUNT), CategoryId(GROCERIES), 10_000, " Hyra ", "", "2026-11-01", me, mode, ratio, exact, fullOn),
dayOfMonth = 1, startPeriod = "2026-11", endPeriod = null, paused = false,
)
val household = listOf(me, partner)
val default = requireNotNull(template(SplitMode.DEFAULT).toWire(household))
assertTrue("default: no splits", default.splits.isEmpty())
assertEquals("Hyra", default.description)
assertEquals("default", default.splitMode)
val ratio = requireNotNull(template(SplitMode.RATIO, ratio = mapOf(partner to 4_000, me to 6_000)).toWire(household))
assertEquals(listOf(TemplateSplitDto(ME, 6_000, null), TemplateSplitDto(PARTNER, 4_000, null)), ratio.splits)
val full = requireNotNull(template(SplitMode.FULL, fullOn = partner).toWire(household))
assertEquals(listOf(TemplateSplitDto(ME, 0, null), TemplateSplitDto(PARTNER, 10_000, null)), full.splits)
val exact = requireNotNull(template(SplitMode.EXACT, exact = mapOf(me to 2_500L, partner to 7_500L)).toWire(household))
assertEquals(listOf(TemplateSplitDto(ME, null, 2_500), TemplateSplitDto(PARTNER, null, 7_500)), exact.splits)
assertNull("ratio without bp", template(SplitMode.RATIO).toWire(household))
assertNull("full without a bearer", template(SplitMode.FULL).toWire(household))
assertNull("full before the partner joined", template(SplitMode.FULL, fullOn = me).toWire(listOf(me)))
}
@Test
fun effectLine_theFourCasesOfSection9_2() = runSync {
// §9.2 from the viewer (me): I paid and Anna has a share; Anna paid and I have a share; I paid it all for
// myself; Anna paid it all for herself.
fun record(payer: String, mine: Long, hers: Long) = ExpenseRecord(
se.hagstedt.kvitt.data.db.ExpenseEntity(
"e", ACCOUNT, GROCERIES, mine + hers, "x", "", "2026-10-10", payer, "exact", null, null, ME, ME,
FakeServer.T0, FakeServer.T0, null, 1, false, null, 0,
),
listOf(se.hagstedt.kvitt.data.db.ExpenseSplitEntity("e", ME, mine, null), se.hagstedt.kvitt.data.db.ExpenseSplitEntity("e", PARTNER, hers, null)),
)
val s = h.local.snapshot.first { it.me != null }
val me = UserId(ME)
assertEquals(ExpenseEffect.Lent(700), s.effectFor(record(ME, 300, 700), me))
assertEquals(ExpenseEffect.Borrowed(300), s.effectFor(record(PARTNER, 300, 700), me))
assertEquals(ExpenseEffect.PaidForYourself, s.effectFor(record(ME, 1000, 0), me))
assertEquals(ExpenseEffect.NotInvolved, s.effectFor(record(PARTNER, 0, 1000), me))
}
@Test
@@ -26,7 +26,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@@ -63,6 +66,22 @@ import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SyncRejectionReason
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.window.Dialog
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.SplitMode
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -74,6 +93,8 @@ data class SyncStatusActions(
val onSyncNow: () -> Unit = {},
val onDismiss: (String) -> Unit = {},
val onOpenDateSettings: () -> Unit = {},
val onOpenRejection: (String) -> Unit = {},
val onCloseRejection: () -> Unit = {},
)
/**
@@ -97,6 +118,8 @@ fun SyncStatusScreen(
onOpenSettlement = onOpenSettlement,
onSyncNow = viewModel::syncNow,
onDismiss = viewModel::dismiss,
onOpenRejection = viewModel::openRejection,
onCloseRejection = viewModel::closeRejection,
onOpenDateSettings = {
try {
context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
@@ -125,6 +148,7 @@ fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Mo
}
ProvideHousehold(ready.me, ready.partner) {
SyncStatusList(ready, actions, padding)
ready.opened?.let { opened -> opened.lost?.let { LostVersionDialog(opened, it, ready, actions) } }
}
}
}
@@ -270,9 +294,8 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
}
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
val open = {
if (settlement) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
}
// D-109: the lost version read-only first; without one (it cannot be shown) the current version directly.
val open = { if (rejection.lost != null) actions.onOpenRejection(rejection.mutationId) else openCurrent(rejection, actions) }
Column(
Modifier
.fillMaxWidth()
@@ -299,3 +322,95 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
}
}
}
/** S-8 or S-10 for the rejected entity: the server's version, or its deleted or missing state. */
private fun openCurrent(rejection: Rejection, actions: SyncStatusActions) {
if (rejection.entityType == EntityType.SETTLEMENT) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
}
/**
* The lost local version of a rejected change, read-only (§13.3 S-16, D-109): "Din version", the ledger row it would
* have been (no tap), the expense's account, payer, split and notes, or the payment's note; "Du tog bort den här"
* when the refused change was a deletion. "Visa nuvarande version" opens S-8 / S-10 with the server's version.
*/
@Composable
private fun LostVersionDialog(rejection: Rejection, lost: RejectedVersion, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
// The dialog window re-provides its own configuration and density; carry the screen's (language, font scale),
// which are the same on a phone and differ only where a preview or test overrides them.
val configuration = LocalConfiguration.current
val density = LocalDensity.current
Dialog(onDismissRequest = actions.onCloseRejection) {
CompositionLocalProvider(LocalConfiguration provides configuration, LocalDensity provides density) {
LostVersionCard(rejection, lost, ui, actions)
}
}
}
@Composable
private fun LostVersionCard(rejection: Rejection, lost: RejectedVersion, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
Surface(shape = KvittTheme.shapes.card, color = scheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) {
Column(
Modifier.verticalScroll(rememberScrollState()).padding(KvittDimens.dialogPadding),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
Text(stringResource(R.string.home_sync_lost_title), Modifier.semantics { heading() }, style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.home_sync_lost_body), style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
if (lost.deleted) KvittBadge(stringResource(R.string.home_sync_lost_deleted), icon = KvittIcon.Delete)
LostFacts(lost, ui, if (lost.deleted) Modifier.alpha(KvittDimens.DELETED_ALPHA) else Modifier)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
KvittButton(stringResource(DesignR.string.common_close), actions.onCloseRejection, kind = ButtonKind.Text)
KvittButton(
stringResource(R.string.home_sync_lost_show_current),
{
actions.onCloseRejection()
openCurrent(rejection, actions)
},
kind = ButtonKind.Tonal,
)
}
}
}
}
@Composable
private fun LostFacts(lost: RejectedVersion, ui: SyncStatusUi.Ready, modifier: Modifier) {
val row = lost.row
val facts = buildList<@Composable (SegmentedItemScope) -> Unit> {
when (row) {
is LedgerRow.ExpenseRow -> {
add { item -> ExpenseRow(item, row, onClick = null) }
lost.accountName?.let { name -> add { item -> FactRow(item, KvittIcon.Inventory2, stringResource(DesignR.string.editor_account), name) } }
lost.payer?.let { payer -> add { item -> FactRow(item, KvittIcon.Person, stringResource(DesignR.string.editor_paid_by), personName(payer)) } }
lost.mode?.let { mode -> add { item -> FactRow(item, KvittIcon.CallSplit, stringResource(DesignR.string.editor_split), lostModeLabel(mode, lost, ui)) } }
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
}
is LedgerRow.SettlementRow -> {
add { item -> SettlementRow(item, row, stringResource(DesignR.string.ledger_settlement_label), onClick = null) }
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
}
}
}
SegmentedGroup(facts.size, modifier) { item -> facts[item.index](item) }
}
/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna", as on S-8. */
@Composable
private fun lostModeLabel(mode: SplitMode, lost: RejectedVersion, ui: SyncStatusUi.Ready): String {
val language = kvittLanguage()
val mineBp = lost.shares.firstOrNull { it.userId == ui.me.id }?.shareBp
val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
return when (mode) {
SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else splitModeLabel(mode, defaultModeLabel(HALF_PERCENT))
SplitMode.RATIO -> splitModeLabel(mode, "") + (ratio?.let { " $it" } ?: "")
SplitMode.EXACT -> splitModeLabel(mode, "")
SplitMode.FULL -> {
val bearer = lost.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == ui.me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, ui.partner?.displayName.orEmpty())
}
}
}
private const val BP_TOTAL = 10_000
private const val HALF_PERCENT = 50
@@ -3,12 +3,14 @@ package se.hagstedt.kvitt.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
@@ -22,9 +24,9 @@ sealed interface SyncStatusUi {
/**
* @property me the viewer and [partner] the other person: `stale` names the rejection's actor, or the partner when
* it has none (§14.4.6).
* it has none (§14.4.6). [opened] is the rejection whose lost version the read-only sheet shows (D-109).
*/
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?) : SyncStatusUi {
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val opened: Rejection? = null) : SyncStatusUi {
/** The last attempt failed while online ("Synkningen misslyckades"); offline wins, as on the pill (§3.6). */
val failed: Boolean get() = online && status.summary.lastAttemptFailed
@@ -44,16 +46,32 @@ class SyncStatusViewModel @Inject constructor(
connectivity: ConnectivityRepository,
session: SessionRepository,
) : ViewModel() {
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session) { status, online, s ->
if (s is SessionState.Paired) SyncStatusUi.Ready(status, online, s.me, s.partner) else SyncStatusUi.Starting
private val openedId = MutableStateFlow<String?>(null)
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session, openedId) { status, online, s, opened ->
if (s is SessionState.Paired) {
SyncStatusUi.Ready(status, online, s.me, s.partner, status.rejections.firstOrNull { it.mutationId == opened })
} else {
SyncStatusUi.Starting
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, SyncStatusUi.Starting)
/** "Öppna" on a rejection with a lost version: the read-only sheet (S-16, D-109). */
fun openRejection(mutationId: String) {
openedId.value = mutationId
}
fun closeRejection() {
openedId.value = null
}
fun syncNow() {
viewModelScope.launch { sync.syncNow() }
}
/** "Ta bort från listan": works offline (design-spec S-16). */
fun dismiss(mutationId: String) {
if (openedId.value == mutationId) openedId.value = null
viewModelScope.launch { sync.dismiss(mutationId) }
}
}
@@ -40,6 +40,10 @@
<string name="home_sync_status_dismiss">Ta bort från listan</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Öppna</string>
<string name="home_sync_lost_title">Din version</string>
<string name="home_sync_lost_body">Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här.</string>
<string name="home_sync_lost_deleted">Du tog bort den här</string>
<string name="home_sync_lost_show_current">Visa nuvarande version</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Din ändring ersattes av en nyare ändring från %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
@@ -40,6 +40,10 @@
<string name="home_sync_status_dismiss">Remove from list</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Open</string>
<string name="home_sync_lost_title">Your version</string>
<string name="home_sync_lost_body">This is how it looked on your phone. It was not saved and cannot be changed here.</string>
<string name="home_sync_lost_deleted">You deleted this</string>
<string name="home_sync_lost_show_current">Show current version</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Your change was replaced by a newer change from %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
@@ -23,10 +23,12 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi
vm.ui.value
}
/** Sync status with [scenario]. */
fun syncState(scenario: FakeScenario): SyncStatusUi = withMain {
/** Sync status with [scenario] after [act] on its ViewModel. */
fun syncState(scenario: FakeScenario, act: (SyncStatusViewModel) -> Unit = {}): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
val vm = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session)
act(vm)
vm.ui.value
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
@@ -56,6 +58,9 @@ val homeStates: List<ScreenState> = listOf(
sync("S-16-sync-error", HomeScenarios.pending.copy(lastAttemptFailed = true)),
sync("S-16-sync-offline", HomeScenarios.syncOffline),
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
sync("S-16-sync-lost", HomeScenarios.syncLoaded, largeFont = true) { it.openRejection("m-1") },
sync("S-16-sync-lost-payment", HomeScenarios.allRejections) { it.openRejection("m-3") },
sync("S-16-sync-lost-deleted", HomeScenarios.allRejections) { it.openRejection("m-5") },
)
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
@@ -63,8 +68,8 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState {
val ui = syncState(scenario)
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (SyncStatusViewModel) -> Unit = {}): ScreenState {
val ui = syncState(scenario, act)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
@@ -93,7 +98,7 @@ class HomeScreenshotTest(private val state: String) {
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
"S-16-sync-offline", "S-16-sync-long",
"S-16-sync-offline", "S-16-sync-long", "S-16-sync-lost", "S-16-sync-lost-payment", "S-16-sync-lost-deleted",
).map { arrayOf<Any>(it) }
}
}
@@ -163,4 +163,19 @@ class SyncStatusViewModelTest {
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
}
@Test
fun openingARejectionShowsItsLostVersion_andCloseOrDismissHidesIt() {
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
val opened = (vm.ui.value as SyncStatusUi.Ready).opened
assertEquals(HomeScenarios.lostBabette, opened?.lost)
withMain { vm.closeRejection() }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
}
}
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.feature.home
import android.content.res.Configuration
import android.os.LocaleList
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -22,6 +23,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import org.robolectric.Shadows.shadowOf
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
@@ -75,6 +77,11 @@ object ScreenSnapshots {
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
// The screen (and a dialog window: S-16's lost version, S-17's install dialog) draw in a later frame; one
// more frame and an idle main looper, so the capture shows this variant and not the previous one.
shadowOf(Looper.getMainLooper()).idle()
compose.mainClock.advanceTimeByFrame()
shadowOf(Looper.getMainLooper()).idle()
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

@@ -100,7 +100,16 @@ fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAva
SegmentedGroup(2) { item ->
when (item.index) {
0 -> FactRow(item, KvittIcon.Dns, stringResource(R.string.settings_about_server), about.serverUrl)
else -> FactRow(item, KvittIcon.Memory, stringResource(R.string.settings_about_server_version), about.serverVersion ?: stringResource(R.string.settings_about_server_version_unknown))
else -> {
val version = about.serverVersion
val schema = about.schemaVersion
val value = when {
version == null -> stringResource(R.string.settings_about_server_version_unknown)
schema == null -> version
else -> stringResource(R.string.settings_about_server_version_schema, version, schema)
}
FactRow(item, KvittIcon.Memory, stringResource(R.string.settings_about_server_version), value)
}
}
}
if (updateCheckAvailable) {
@@ -178,6 +178,7 @@
<!-- label; value from /health -->
<string name="settings_about_server_version">Serverversion</string>
<string name="settings_about_server_version_unknown">Okänd</string>
<string name="settings_about_server_version_schema">%1$s (schema %2$d)</string>
<string name="settings_category_archived_snackbar">%1$s arkiverades</string>
<string name="settings_category_unarchived_snackbar">%1$s återställdes</string>
<string name="settings_recurring_summary_none">Inga</string>
@@ -178,6 +178,7 @@
<!-- label; value from /health -->
<string name="settings_about_server_version">Server version</string>
<string name="settings_about_server_version_unknown">Unknown</string>
<string name="settings_about_server_version_schema">%1$s (schema %2$d)</string>
<string name="settings_category_archived_snackbar">%1$s was archived</string>
<string name="settings_category_unarchived_snackbar">%1$s was restored</string>
<string name="settings_recurring_summary_none">None</string>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -153,7 +153,8 @@ class SettleViewModel @Inject constructor(
home.home().collect { h -> mutable.update { it.copy(me = h.me, partner = h.partner) } }
}
viewModelScope.launch {
val start = settlements.start(editing, if (editing == null) fixedAccountId else null)
// Null only without a partner, where S-9 is not offered (AC-5.1, D-111): the screen stays empty.
val start = settlements.start(editing, if (editing == null) fixedAccountId else null) ?: return@launch
mutable.update { s -> s.withResult(start, propose = true).copy(loading = false, date = start.date, note = start.note, rowsEdited = editing != null) }
}
}
@@ -234,7 +235,7 @@ class SettleViewModel @Inject constructor(
val request = request(current, fresh) ?: return
recomputing?.cancel()
recomputing = viewModelScope.launch {
val result = settlements.recompute(request)
val result = settlements.recompute(request) ?: return@launch
mutable.update { it.withResult(result, fresh) }
}
}
@@ -9,7 +9,6 @@
<string name="settle_swap">Byt riktning</string>
<string name="settle_direction">Riktning</string>
<string name="settle_amount">Belopp</string>
<string name="settle_empty_settled">Ni är kvitt just nu.</string>
<string name="settle_empty_hint">Skriv ett belopp så föreslår Kvitt en fördelning.</string>
<string name="settle_allocation">Fördelning</string>
<string name="settle_row_debt">Skuld %1$s</string>
@@ -9,7 +9,6 @@
<string name="settle_swap">Swap direction</string>
<string name="settle_direction">Direction</string>
<string name="settle_amount">Amount</string>
<string name="settle_empty_settled">You are all settled up right now.</string>
<string name="settle_empty_hint">Enter an amount and Kvitt suggests an allocation.</string>
<string name="settle_allocation">Allocation</string>
<string name="settle_row_debt">Debt %1$s</string>
+5
View File
@@ -313,6 +313,10 @@ home_sync_status_row_pending,%1$s · waiting to sync,%1$s · väntar på synk,mo
home_sync_status_rejected_title,Changes that were not synced,Ändringar som inte synkades,module=home
home_sync_status_dismiss,Remove from list,Ta bort från listan,module=home
home_sync_status_open,Open,Öppna,module=home; opens the rejected change read-only
home_sync_lost_title,Your version,Din version,module=home; title of the read-only sheet of a rejected change (S-16; D-109)
home_sync_lost_body,This is how it looked on your phone. It was not saved and cannot be changed here.,Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här.,module=home; S-16 lost-version sheet
home_sync_lost_deleted,You deleted this,Du tog bort den här,module=home; S-16 lost-version sheet when the refused change was a deletion
home_sync_lost_show_current,Show current version,Visa nuvarande version,module=home; S-16 lost-version sheet: opens S-8 / S-10
home_sync_reject_stale,Your change was replaced by a newer change from %1$s,Din ändring ersattes av en nyare ändring från %1$s,module=home; §14.4.6
home_sync_reject_stale_help,%1$s's version applies. Open the expense if you want to make your change again.,%1$ss version gäller. Öppna utgiften om du vill göra din ändring igen.,module=home; sv genitive as onboarding_profile_joining
home_sync_reject_stale_help_payment,%1$s's version applies. Open the payment if you want to make your change again.,%1$ss version gäller. Öppna betalningen om du vill göra din ändring igen.,module=home; as home_sync_reject_stale_help
@@ -569,6 +573,7 @@ settings_about_version,Kvitt %1$s (%2$d),Kvitt %1$s (%2$d),module=settings; vers
settings_about_server,Server,Server,module=settings
settings_about_server_version,Server version,Serverversion,module=settings; label; value from /health
settings_about_server_version_unknown,Unknown,Okänd,module=settings
settings_about_server_version_schema,%1$s (schema %2$d),%1$s (schema %2$d),"module=settings; S-22 server version from /health with its schema version, e.g. 1.1.0 (schema 3) (D-110)"
settings_category_archived_snackbar,%1$s was archived,%1$s arkiverades,module=settings; S-15 snackbar after Arkivera; %1$s category name; M7f
settings_category_unarchived_snackbar,%1$s was restored,%1$s återställdes,module=settings; S-15 snackbar after Återställ från arkiv; %1$s category name; M7f
settings_recurring_summary_none,None,Inga,module=settings; S-13 recurring row subtitle without templates; M7f
Can't render this file because it contains an unexpected character in line 161 and column 79.