M12f: S-16 lost version read-only, S-22 versions from /health, settle start without a partner (D-109 to D-111)

- Rejection.lost: RejectedVersion (the pushed entity from sync_rejections.rejected_payload as a ledger row, account,
  payer, split, notes, deleted); S-16 "Öppna" shows it read-only in a dialog with "Visa nuvarande version" (S-8/S-10);
  without one it opens the current version as before. Ledger rows take a null onClick (read-only).
- AboutState.schemaVersion; AboutRepositoryImpl calls E-1 /health once per collection when online; header fallback.
  S-22 shows "1.1.0 (schema 3)".
- SettlementRepository.start/recompute return null without a session or partner; save is Invalid(payer); no
  checkNotNull/requireNotNull in :data.
- :feature:home screenshot harness: one more frame and an idle looper before each capture; the committed sv_dark,
  200 % and dialog goldens were one variant stale (light instead of dark, en instead of sv_200).
- Tests: lost version (stale, refused deletion, unreadable payload), /health online and offline, settle without a
  partner, category reorder PATCHes, recurring toWire splits, §9.2 effect cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:32:24 +02:00
parent 7fb86f5524
commit dc7a296d70
90 changed files with 550 additions and 70 deletions
@@ -26,7 +26,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@@ -63,6 +66,22 @@ import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SyncRejectionReason
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.window.Dialog
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.SplitMode
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -74,6 +93,8 @@ data class SyncStatusActions(
val onSyncNow: () -> Unit = {},
val onDismiss: (String) -> Unit = {},
val onOpenDateSettings: () -> Unit = {},
val onOpenRejection: (String) -> Unit = {},
val onCloseRejection: () -> Unit = {},
)
/**
@@ -97,6 +118,8 @@ fun SyncStatusScreen(
onOpenSettlement = onOpenSettlement,
onSyncNow = viewModel::syncNow,
onDismiss = viewModel::dismiss,
onOpenRejection = viewModel::openRejection,
onCloseRejection = viewModel::closeRejection,
onOpenDateSettings = {
try {
context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
@@ -125,6 +148,7 @@ fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Mo
}
ProvideHousehold(ready.me, ready.partner) {
SyncStatusList(ready, actions, padding)
ready.opened?.let { opened -> opened.lost?.let { LostVersionDialog(opened, it, ready, actions) } }
}
}
}
@@ -270,9 +294,8 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
}
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
val open = {
if (settlement) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
}
// D-109: the lost version read-only first; without one (it cannot be shown) the current version directly.
val open = { if (rejection.lost != null) actions.onOpenRejection(rejection.mutationId) else openCurrent(rejection, actions) }
Column(
Modifier
.fillMaxWidth()
@@ -299,3 +322,95 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
}
}
}
/** S-8 or S-10 for the rejected entity: the server's version, or its deleted or missing state. */
private fun openCurrent(rejection: Rejection, actions: SyncStatusActions) {
if (rejection.entityType == EntityType.SETTLEMENT) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
}
/**
* The lost local version of a rejected change, read-only (§13.3 S-16, D-109): "Din version", the ledger row it would
* have been (no tap), the expense's account, payer, split and notes, or the payment's note; "Du tog bort den här"
* when the refused change was a deletion. "Visa nuvarande version" opens S-8 / S-10 with the server's version.
*/
@Composable
private fun LostVersionDialog(rejection: Rejection, lost: RejectedVersion, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
// The dialog window re-provides its own configuration and density; carry the screen's (language, font scale),
// which are the same on a phone and differ only where a preview or test overrides them.
val configuration = LocalConfiguration.current
val density = LocalDensity.current
Dialog(onDismissRequest = actions.onCloseRejection) {
CompositionLocalProvider(LocalConfiguration provides configuration, LocalDensity provides density) {
LostVersionCard(rejection, lost, ui, actions)
}
}
}
@Composable
private fun LostVersionCard(rejection: Rejection, lost: RejectedVersion, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
Surface(shape = KvittTheme.shapes.card, color = scheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) {
Column(
Modifier.verticalScroll(rememberScrollState()).padding(KvittDimens.dialogPadding),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
Text(stringResource(R.string.home_sync_lost_title), Modifier.semantics { heading() }, style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.home_sync_lost_body), style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
if (lost.deleted) KvittBadge(stringResource(R.string.home_sync_lost_deleted), icon = KvittIcon.Delete)
LostFacts(lost, ui, if (lost.deleted) Modifier.alpha(KvittDimens.DELETED_ALPHA) else Modifier)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
KvittButton(stringResource(DesignR.string.common_close), actions.onCloseRejection, kind = ButtonKind.Text)
KvittButton(
stringResource(R.string.home_sync_lost_show_current),
{
actions.onCloseRejection()
openCurrent(rejection, actions)
},
kind = ButtonKind.Tonal,
)
}
}
}
}
@Composable
private fun LostFacts(lost: RejectedVersion, ui: SyncStatusUi.Ready, modifier: Modifier) {
val row = lost.row
val facts = buildList<@Composable (SegmentedItemScope) -> Unit> {
when (row) {
is LedgerRow.ExpenseRow -> {
add { item -> ExpenseRow(item, row, onClick = null) }
lost.accountName?.let { name -> add { item -> FactRow(item, KvittIcon.Inventory2, stringResource(DesignR.string.editor_account), name) } }
lost.payer?.let { payer -> add { item -> FactRow(item, KvittIcon.Person, stringResource(DesignR.string.editor_paid_by), personName(payer)) } }
lost.mode?.let { mode -> add { item -> FactRow(item, KvittIcon.CallSplit, stringResource(DesignR.string.editor_split), lostModeLabel(mode, lost, ui)) } }
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
}
is LedgerRow.SettlementRow -> {
add { item -> SettlementRow(item, row, stringResource(DesignR.string.ledger_settlement_label), onClick = null) }
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
}
}
}
SegmentedGroup(facts.size, modifier) { item -> facts[item.index](item) }
}
/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna", as on S-8. */
@Composable
private fun lostModeLabel(mode: SplitMode, lost: RejectedVersion, ui: SyncStatusUi.Ready): String {
val language = kvittLanguage()
val mineBp = lost.shares.firstOrNull { it.userId == ui.me.id }?.shareBp
val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
return when (mode) {
SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else splitModeLabel(mode, defaultModeLabel(HALF_PERCENT))
SplitMode.RATIO -> splitModeLabel(mode, "") + (ratio?.let { " $it" } ?: "")
SplitMode.EXACT -> splitModeLabel(mode, "")
SplitMode.FULL -> {
val bearer = lost.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == ui.me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, ui.partner?.displayName.orEmpty())
}
}
}
private const val BP_TOTAL = 10_000
private const val HALF_PERCENT = 50
@@ -3,12 +3,14 @@ package se.hagstedt.kvitt.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
@@ -22,9 +24,9 @@ sealed interface SyncStatusUi {
/**
* @property me the viewer and [partner] the other person: `stale` names the rejection's actor, or the partner when
* it has none (§14.4.6).
* it has none (§14.4.6). [opened] is the rejection whose lost version the read-only sheet shows (D-109).
*/
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?) : SyncStatusUi {
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val opened: Rejection? = null) : SyncStatusUi {
/** The last attempt failed while online ("Synkningen misslyckades"); offline wins, as on the pill (§3.6). */
val failed: Boolean get() = online && status.summary.lastAttemptFailed
@@ -44,16 +46,32 @@ class SyncStatusViewModel @Inject constructor(
connectivity: ConnectivityRepository,
session: SessionRepository,
) : ViewModel() {
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session) { status, online, s ->
if (s is SessionState.Paired) SyncStatusUi.Ready(status, online, s.me, s.partner) else SyncStatusUi.Starting
private val openedId = MutableStateFlow<String?>(null)
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session, openedId) { status, online, s, opened ->
if (s is SessionState.Paired) {
SyncStatusUi.Ready(status, online, s.me, s.partner, status.rejections.firstOrNull { it.mutationId == opened })
} else {
SyncStatusUi.Starting
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, SyncStatusUi.Starting)
/** "Öppna" on a rejection with a lost version: the read-only sheet (S-16, D-109). */
fun openRejection(mutationId: String) {
openedId.value = mutationId
}
fun closeRejection() {
openedId.value = null
}
fun syncNow() {
viewModelScope.launch { sync.syncNow() }
}
/** "Ta bort från listan": works offline (design-spec S-16). */
fun dismiss(mutationId: String) {
if (openedId.value == mutationId) openedId.value = null
viewModelScope.launch { sync.dismiss(mutationId) }
}
}
@@ -40,6 +40,10 @@
<string name="home_sync_status_dismiss">Ta bort från listan</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Öppna</string>
<string name="home_sync_lost_title">Din version</string>
<string name="home_sync_lost_body">Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här.</string>
<string name="home_sync_lost_deleted">Du tog bort den här</string>
<string name="home_sync_lost_show_current">Visa nuvarande version</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Din ändring ersattes av en nyare ändring från %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
@@ -40,6 +40,10 @@
<string name="home_sync_status_dismiss">Remove from list</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Open</string>
<string name="home_sync_lost_title">Your version</string>
<string name="home_sync_lost_body">This is how it looked on your phone. It was not saved and cannot be changed here.</string>
<string name="home_sync_lost_deleted">You deleted this</string>
<string name="home_sync_lost_show_current">Show current version</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Your change was replaced by a newer change from %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
@@ -23,10 +23,12 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi
vm.ui.value
}
/** Sync status with [scenario]. */
fun syncState(scenario: FakeScenario): SyncStatusUi = withMain {
/** Sync status with [scenario] after [act] on its ViewModel. */
fun syncState(scenario: FakeScenario, act: (SyncStatusViewModel) -> Unit = {}): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
val vm = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session)
act(vm)
vm.ui.value
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
@@ -56,6 +58,9 @@ val homeStates: List<ScreenState> = listOf(
sync("S-16-sync-error", HomeScenarios.pending.copy(lastAttemptFailed = true)),
sync("S-16-sync-offline", HomeScenarios.syncOffline),
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
sync("S-16-sync-lost", HomeScenarios.syncLoaded, largeFont = true) { it.openRejection("m-1") },
sync("S-16-sync-lost-payment", HomeScenarios.allRejections) { it.openRejection("m-3") },
sync("S-16-sync-lost-deleted", HomeScenarios.allRejections) { it.openRejection("m-5") },
)
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
@@ -63,8 +68,8 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState {
val ui = syncState(scenario)
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (SyncStatusViewModel) -> Unit = {}): ScreenState {
val ui = syncState(scenario, act)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
@@ -93,7 +98,7 @@ class HomeScreenshotTest(private val state: String) {
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
"S-16-sync-offline", "S-16-sync-long",
"S-16-sync-offline", "S-16-sync-long", "S-16-sync-lost", "S-16-sync-lost-payment", "S-16-sync-lost-deleted",
).map { arrayOf<Any>(it) }
}
}
@@ -163,4 +163,19 @@ class SyncStatusViewModelTest {
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
}
@Test
fun openingARejectionShowsItsLostVersion_andCloseOrDismissHidesIt() {
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
val opened = (vm.ui.value as SyncStatusUi.Ready).opened
assertEquals(HomeScenarios.lostBabette, opened?.lost)
withMain { vm.closeRejection() }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
}
}
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.feature.home
import android.content.res.Configuration
import android.os.LocaleList
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -22,6 +23,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import org.robolectric.Shadows.shadowOf
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
@@ -75,6 +77,11 @@ object ScreenSnapshots {
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
// The screen (and a dialog window: S-16's lost version, S-17's install dialog) draw in a later frame; one
// more frame and an idle main looper, so the capture shows this variant and not the previous one.
shadowOf(Looper.getMainLooper()).idle()
compose.mainClock.advanceTimeByFrame()
shadowOf(Looper.getMainLooper()).idle()
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

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

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -153,7 +153,8 @@ class SettleViewModel @Inject constructor(
home.home().collect { h -> mutable.update { it.copy(me = h.me, partner = h.partner) } }
}
viewModelScope.launch {
val start = settlements.start(editing, if (editing == null) fixedAccountId else null)
// Null only without a partner, where S-9 is not offered (AC-5.1, D-111): the screen stays empty.
val start = settlements.start(editing, if (editing == null) fixedAccountId else null) ?: return@launch
mutable.update { s -> s.withResult(start, propose = true).copy(loading = false, date = start.date, note = start.note, rowsEdited = editing != null) }
}
}
@@ -234,7 +235,7 @@ class SettleViewModel @Inject constructor(
val request = request(current, fresh) ?: return
recomputing?.cancel()
recomputing = viewModelScope.launch {
val result = settlements.recompute(request)
val result = settlements.recompute(request) ?: return@launch
mutable.update { it.withResult(result, fresh) }
}
}
@@ -9,7 +9,6 @@
<string name="settle_swap">Byt riktning</string>
<string name="settle_direction">Riktning</string>
<string name="settle_amount">Belopp</string>
<string name="settle_empty_settled">Ni är kvitt just nu.</string>
<string name="settle_empty_hint">Skriv ett belopp så föreslår Kvitt en fördelning.</string>
<string name="settle_allocation">Fördelning</string>
<string name="settle_row_debt">Skuld %1$s</string>
@@ -9,7 +9,6 @@
<string name="settle_swap">Swap direction</string>
<string name="settle_direction">Direction</string>
<string name="settle_amount">Amount</string>
<string name="settle_empty_settled">You are all settled up right now.</string>
<string name="settle_empty_hint">Enter an amount and Kvitt suggests an allocation.</string>
<string name="settle_allocation">Allocation</string>
<string name="settle_row_debt">Debt %1$s</string>