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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
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>
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
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>
|
||||
|
||||