M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

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 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
@@ -18,10 +18,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -74,13 +76,17 @@ data class SyncStatusActions(
val onOpenDateSettings: () -> Unit = {},
)
/** Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). */
/**
* Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5).
* [snackbarHostState] is hosted in this screen's scaffold (a result snackbar after returning here, D-071).
*/
@Composable
fun SyncStatusScreen(
onBack: () -> Unit,
onOpenExpense: (ExpenseId) -> Unit,
onOpenSettlement: (SettlementId) -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: SyncStatusViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
@@ -99,16 +105,17 @@ fun SyncStatusScreen(
}
},
)
SyncStatusContent(ui, actions, modifier)
SyncStatusContent(ui, actions, modifier, snackbarHostState)
}
private const val LOG_TAG = "KvittSyncStatus"
/** The stateless sync status screen, rendered by [SyncStatusScreen], previews and screenshot tests. */
@Composable
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier) {
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) {
KvittScaffold(
modifier = modifier,
snackbarHostState = snackbarHostState,
topBar = { KvittTopAppBar(stringResource(R.string.home_sync_status_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
) { padding ->
val ready = ui as? SyncStatusUi.Ready
@@ -246,10 +253,11 @@ private fun PendingGroup(rows: List<LedgerRow>, actions: SyncStatusActions) {
private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
val clock = LocalKvittClock.current
val partnerName = ui.partner?.displayName.orEmpty()
// §14.4.6 "<actor>": who made the server's version; the partner when the server had none to name.
val actorName = (rejection.actor ?: ui.partner)?.displayName.orEmpty()
val settlement = rejection.entityType == EntityType.SETTLEMENT
val reason = when (rejection.reason) {
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, partnerName)
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, actorName)
SyncRejectionReason.DELETED -> stringResource(R.string.home_sync_reject_deleted)
SyncRejectionReason.VALIDATION_FAILED -> stringResource(R.string.home_sync_reject_validation)
SyncRejectionReason.REFERENCE_NOT_FOUND -> stringResource(R.string.home_sync_reject_reference)
@@ -257,8 +265,8 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
}
val help = when {
rejection.reason != SyncRejectionReason.STALE -> null
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, partnerName)
else -> stringResource(R.string.home_sync_reject_stale_help, partnerName)
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, actorName)
else -> stringResource(R.string.home_sync_reject_stale_help, actorName)
}
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
@@ -21,11 +21,13 @@ sealed interface SyncStatusUi {
data object Starting : SyncStatusUi
/**
* @property me the viewer and [partner] the other person: `stale` is explained with the partner's name (§14.4.6;
* `Rejection` carries no actor, see the M7b report).
* @property failed the last attempt failed: `SyncSummary` has no such field yet, so the ViewModel passes false.
* @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).
*/
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val failed: Boolean = false) : SyncStatusUi {
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?) : 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
/** Nothing pending, nothing refused, no clock problem: "Allt är synkat". */
val allSynced: Boolean
get() = status.pending.isEmpty() && status.rejections.isEmpty() && !status.summary.clockSkew && !status.summary.running && online && !failed
@@ -23,11 +23,10 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi
vm.ui.value
}
/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */
fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain {
/** Sync status with [scenario]. */
fun syncState(scenario: FakeScenario): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui
SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
@@ -54,7 +53,7 @@ val homeStates: List<ScreenState> = listOf(
sync("S-16-sync-rejections", HomeScenarios.allRejections),
sync("S-16-sync-clock", HomeScenarios.clockSkew),
sync("S-16-sync-running", HomeScenarios.syncing),
sync("S-16-sync-error", HomeScenarios.pending, failed = true),
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),
)
@@ -64,8 +63,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, failed: Boolean = false): ScreenState {
val ui = syncState(scenario, failed)
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState {
val ui = syncState(scenario)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
@@ -12,6 +12,7 @@ import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateState
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
@@ -19,7 +20,7 @@ private class RecordingUpdates(initial: UpdateState) : UpdateRepository {
val flow = MutableStateFlow(initial)
var installs = 0
override val state: Flow<UpdateState> = flow
override suspend fun check() = Unit
override suspend fun check(): UpdateCheck = UpdateCheck.UpToDate
override suspend fun install() {
installs += 1
}