diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt index c74f31b..5ba0728 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt @@ -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, diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt index fe54155..ff78758 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt @@ -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", diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt index 6d79a1a..4e42387 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt @@ -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 { val s = store.current diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt index 0bd6579..c321308 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt @@ -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, ), ) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt index e4ee692..8628be8 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt @@ -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 = mapOf( diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt index 8fce0c4..143cb20 100644 --- a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt @@ -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()) } diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt index fd0aba2..3924075 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt @@ -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>, val deleted: Boolean, val pending: Boolean, val timeline: List) 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 suspend fun delete(id: SettlementId) fun detail(id: SettlementId): Flow diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt index 1cd5e87..e4ec246 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt @@ -6,9 +6,19 @@ import kotlinx.coroutines.flow.Flow /** * [actor]: who made the server's current version (its `updated_by_user_id`, the "" 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, val notes: String, val deleted: Boolean) data class SyncStatusState(val summary: SyncSummary, val pending: List, val rejections: List) @@ -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 diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt index 8a06781..50de49b 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt @@ -55,6 +55,10 @@ class KvittApi( suspend fun acceptInvite(baseUrl: String, request: AcceptInviteRequest): ApiResult = 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 = + withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/health", authenticated = false) } + // E-5, E-6: unauthenticated. suspend fun apkLatest(): ApiResult = withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/apk/latest", authenticated = false) } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt index fbf5d5f..91e738a 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt @@ -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. */ diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt index 40cdd7f..10d6fb6 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt @@ -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): CreateTemplateRequest? { +internal fun NewRecurringTemplate.toWire(household: List): 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, + 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 = combine(credentials.record, serverVersion) { record, server -> - AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, server) - }.distinctUntilChanged() + override fun about(): Flow { + 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() + } } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt index 9e9e89f..04c6388 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt @@ -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 { 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() val amount = request.amountOre if (amount == null) { @@ -145,11 +147,12 @@ private fun SettleState.allocations(): List = 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 diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt index 0b94599..ca364af 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt @@ -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>, 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> = compareByDescending> { it.first }.thenByDescending { it.second } diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt index 0dfadd6..19d47f3 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt @@ -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("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>() + 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? = null, exact: Map? = 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 diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt index 4faef9c..eca8ca9 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt @@ -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 diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt index 3acb84c..7f6bd41 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt @@ -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 = 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(null) + + val ui: StateFlow = 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) } } } diff --git a/android/feature/home/src/main/res/values-sv/strings.xml b/android/feature/home/src/main/res/values-sv/strings.xml index 7b85a62..2cbb748 100644 --- a/android/feature/home/src/main/res/values-sv/strings.xml +++ b/android/feature/home/src/main/res/values-sv/strings.xml @@ -40,6 +40,10 @@ Ta bort från listan Öppna + Din version + Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här. + Du tog bort den här + Visa nuvarande version Din ändring ersattes av en nyare ändring från %1$s diff --git a/android/feature/home/src/main/res/values/strings.xml b/android/feature/home/src/main/res/values/strings.xml index 0c757cf..517a337 100644 --- a/android/feature/home/src/main/res/values/strings.xml +++ b/android/feature/home/src/main/res/values/strings.xml @@ -40,6 +40,10 @@ Remove from list Open + Your version + This is how it looked on your phone. It was not saved and cannot be changed here. + You deleted this + Show current version Your change was replaced by a newer change from %1$s diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt index bf56e7b..ca7f595 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt @@ -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 = 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(it) } } } diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt index 8cbd87f..2658f45 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt @@ -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) + } } diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt index 54702f9..9c2efe2 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt @@ -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") } } diff --git a/android/feature/home/src/test/screenshots/S-16-sync-clock_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-clock_sv_dark.png index bb9d42d..498a576 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-clock_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-clock_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-empty_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-empty_sv_dark.png index a8b4a40..74408da 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-empty_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-empty_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-error_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-error_sv_dark.png index 056155c..c618fb2 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-error_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-error_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-long_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-long_sv_dark.png index a929587..9ffbd34 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-long_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-long_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-long_sv_light_200.png b/android/feature/home/src/test/screenshots/S-16-sync-long_sv_light_200.png index ee2763c..453e012 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-long_sv_light_200.png and b/android/feature/home/src/test/screenshots/S-16-sync-long_sv_light_200.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_en_light.png b/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_en_light.png new file mode 100644 index 0000000..daf88e7 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_sv_dark.png new file mode 100644 index 0000000..b1fa100 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_sv_light.png b/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_sv_light.png new file mode 100644 index 0000000..4cb3c55 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost-deleted_sv_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_en_light.png b/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_en_light.png new file mode 100644 index 0000000..ad238ca Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_sv_dark.png new file mode 100644 index 0000000..fc8bee2 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_sv_light.png b/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_sv_light.png new file mode 100644 index 0000000..e552117 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost-payment_sv_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost_en_light.png b/android/feature/home/src/test/screenshots/S-16-sync-lost_en_light.png new file mode 100644 index 0000000..59e6cf6 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_dark.png new file mode 100644 index 0000000..aa7a404 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_light.png b/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_light.png new file mode 100644 index 0000000..1a34465 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_light_200.png b/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_light_200.png new file mode 100644 index 0000000..8449d4d Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-16-sync-lost_sv_light_200.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-offline_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-offline_sv_dark.png index 55a81c1..cac4a67 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-offline_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-offline_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-rejections_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-rejections_sv_dark.png index 83dc178..0d620b7 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-rejections_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-rejections_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-running_en_light.png b/android/feature/home/src/test/screenshots/S-16-sync-running_en_light.png index d2d0c54..3e06c0d 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-running_en_light.png and b/android/feature/home/src/test/screenshots/S-16-sync-running_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-running_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync-running_sv_dark.png index e0163ff..c94a7c8 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-running_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync-running_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync-running_sv_light.png b/android/feature/home/src/test/screenshots/S-16-sync-running_sv_light.png index 37fbadf..76f34df 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync-running_sv_light.png and b/android/feature/home/src/test/screenshots/S-16-sync-running_sv_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-16-sync_sv_dark.png b/android/feature/home/src/test/screenshots/S-16-sync_sv_dark.png index af110b2..c650119 100644 Binary files a/android/feature/home/src/test/screenshots/S-16-sync_sv_dark.png and b/android/feature/home/src/test/screenshots/S-16-sync_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png index e602321..cf88e0d 100644 Binary files a/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-banner_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png index b783374..8e8e83a 100644 Binary files a/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-failed_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png index e602321..cf88e0d 100644 Binary files a/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-permission_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png index 49a316f..70db8dd 100644 Binary files a/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update-progress_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png index df1200d..cebe1b4 100644 Binary files a/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png and b/android/feature/home/src/test/screenshots/S-17-update_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png index 9621050..ad0c134 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-clock_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png index 783eba5..a126685 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-empty_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png index 7a7c58d..b480a01 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png and b/android/feature/home/src/test/screenshots/S-4-home-loading_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png index 199fc11..e2f9b94 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png index b6ae7f2..19780e8 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png and b/android/feature/home/src/test/screenshots/S-4-home-loading_sv_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png index bb4decf..0e4a93e 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-long_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png b/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png index 958c021..b53aa52 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png and b/android/feature/home/src/test/screenshots/S-4-home-long_sv_light_200.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png index 06c84be..f668259 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-offline_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png index cf08cd0..bd28716 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-only-archived_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png index 83f1c94..6904709 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-owes-you_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png index a3abf32..0fd71af 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-pending_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png index f58fcfd..bc10567 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-rejected_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png index 1c20cb7..b7f1da6 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-settled_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png index 9c506e4..ab4e0d1 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home-syncing_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png index 400268f..c44e050 100644 Binary files a/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png and b/android/feature/home/src/test/screenshots/S-4-home_sv_dark.png differ diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt index 4a32642..01fdf18 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt @@ -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) { diff --git a/android/feature/settings/src/main/res/values-sv/strings.xml b/android/feature/settings/src/main/res/values-sv/strings.xml index 8a96b1d..e070738 100644 --- a/android/feature/settings/src/main/res/values-sv/strings.xml +++ b/android/feature/settings/src/main/res/values-sv/strings.xml @@ -178,6 +178,7 @@ Serverversion Okänd + %1$s (schema %2$d) %1$s arkiverades %1$s återställdes Inga diff --git a/android/feature/settings/src/main/res/values/strings.xml b/android/feature/settings/src/main/res/values/strings.xml index 3c4851d..7c15c16 100644 --- a/android/feature/settings/src/main/res/values/strings.xml +++ b/android/feature/settings/src/main/res/values/strings.xml @@ -178,6 +178,7 @@ Server version Unknown + %1$s (schema %2$d) %1$s was archived %1$s was restored None diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_failed_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_check_failed_en_light.png index f79fad6..9d0293a 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_failed_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_check_failed_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_dark.png index e5ac2b5..4b146f5 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_light.png index f92843c..bfca37d 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_check_failed_sv_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_en_light.png index b798cad..604717b 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_dark.png index aef5749..a66184a 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_light.png index 3d2a0cd..700690d 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_check_up_to_date_sv_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_update_found_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_check_update_found_en_light.png index 513d5ee..d162723 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_update_found_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_check_update_found_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_dark.png index ec43aba..9c069e1 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_light.png index 0264328..7398895 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_check_update_found_sv_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_checking_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_checking_en_light.png index 2178c29..e6f3cde 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_checking_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_checking_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_checking_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_checking_sv_dark.png index d490f63..db22e3a 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_checking_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_checking_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_checking_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_checking_sv_light.png index 35358f4..98eced9 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_checking_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_checking_sv_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_downloading_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_downloading_en_light.png index 48f165d..9e77fe7 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_downloading_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_downloading_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_dark.png index 2e1fd04..4552095 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_light.png index c2ee341..efbd93e 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_downloading_sv_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_online_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_online_en_light.png index 3d73aac..663a87a 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_online_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_online_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_online_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_online_sv_dark.png index 3bf0e61..3587ce3 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_online_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_online_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_online_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_online_sv_light.png index aea72a7..6432811 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_online_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_online_sv_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_update_failed_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_update_failed_en_light.png index 04d2eaf..28fa148 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_update_failed_en_light.png and b/android/feature/settings/src/test/screenshots/s22_about_update_failed_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_dark.png index d340abf..891f533 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_dark.png and b/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_light.png index 1ddac54..eadea58 100644 Binary files a/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_light.png and b/android/feature/settings/src/test/screenshots/s22_about_update_failed_sv_light.png differ diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt index 9d520d9..e020eab 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt @@ -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) } } } diff --git a/android/feature/settle/src/main/res/values-sv/strings.xml b/android/feature/settle/src/main/res/values-sv/strings.xml index 43f2047..b75c29e 100644 --- a/android/feature/settle/src/main/res/values-sv/strings.xml +++ b/android/feature/settle/src/main/res/values-sv/strings.xml @@ -9,7 +9,6 @@ Byt riktning Riktning Belopp - Ni är kvitt just nu. Skriv ett belopp så föreslår Kvitt en fördelning. Fördelning Skuld %1$s diff --git a/android/feature/settle/src/main/res/values/strings.xml b/android/feature/settle/src/main/res/values/strings.xml index 9374bb9..14b08d7 100644 --- a/android/feature/settle/src/main/res/values/strings.xml +++ b/android/feature/settle/src/main/res/values/strings.xml @@ -9,7 +9,6 @@ Swap direction Direction Amount - You are all settled up right now. Enter an amount and Kvitt suggests an allocation. Allocation Debt %1$s diff --git a/docs/ux/strings.csv b/docs/ux/strings.csv index 569a9b7..12ebbb4 100644 --- a/docs/ux/strings.csv +++ b/docs/ux/strings.csv @@ -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