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
@@ -14,11 +14,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -29,6 +31,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind
@@ -80,22 +83,23 @@ interface ExpenseDetailActions {
/**
* S-8, the expense detail with its timeline (design-spec §4 S-8; SPEC AC-8.11, AC-12.4, AC-12.5). [onEdit] opens S-7
* in edit mode; [onDeleted] goes back with the snackbar text "Utgiften togs bort".
* in edit mode; [onDeleted] goes back, where "Utgiften togs bort" (`snackbar_expense_deleted`) is shown, as S-10 does.
* [snackbarHostState] is hosted in this screen, so `:app` can show a result snackbar here (D-071).
*/
@Composable
fun ExpenseDetailScreen(
onBack: () -> Unit,
onEdit: (ExpenseId) -> Unit,
onDeleted: (String) -> Unit,
onDeleted: () -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: ExpenseDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val deleted = stringResource(DesignR.string.snackbar_expense_deleted)
LaunchedEffect(viewModel) {
viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted(deleted) }
viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted() }
}
ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier)
ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier, snackbarHostState)
}
/** The stateless S-8 layout. */
@@ -106,10 +110,19 @@ fun ExpenseDetailContent(
onBack: () -> Unit,
onEdit: (ExpenseId) -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState? = null,
) {
// The scaffold only hosts the snackbar; the body keeps its own system-bar padding.
KvittScaffold(modifier.fillMaxSize(), snackbarHostState = snackbarHostState) { _ ->
DetailSurface(state, actions, onBack, onEdit)
}
}
@Composable
private fun DetailSurface(state: ExpenseDetailUiState, actions: ExpenseDetailActions, onBack: () -> Unit, onEdit: (ExpenseId) -> Unit) {
val detail = state.detail
val me = state.me
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
if (me == null || state.loading) return@Surface
ProvideHousehold(me, state.partner) {
Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
@@ -1,6 +1,7 @@
package se.hagstedt.kvitt.feature.expense
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -123,28 +124,26 @@ interface ExpenseEditorActions {
}
/**
* S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar text for
* the opener ("Utgiften sparades" / offline wording for a new expense, null after an edit: back to S-8 without one);
* [onDeleted] gets "Utgiften togs bort" and goes back past S-8 to its opener.
* S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar string
* resource for the opener (`snackbar_expense_saved` / `snackbar_expense_saved_offline` for a new expense, null after an
* edit: back to S-8 without one); [onDeleted] goes back past S-8 to its opener, which shows `snackbar_expense_deleted`.
* `:app` resolves the resource while the snackbar shows, as for S-9 and S-10.
*/
@Composable
fun ExpenseEditorScreen(
onClose: () -> Unit,
onSaved: (ExpenseId, String?) -> Unit,
onDeleted: (String) -> Unit,
onSaved: (ExpenseId, Int?) -> Unit,
onDeleted: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ExpenseEditorViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val descriptionFocus = remember { FocusRequester() }
val saved = stringResource(DesignR.string.snackbar_expense_saved)
val savedOffline = stringResource(DesignR.string.snackbar_expense_saved_offline)
val deleted = stringResource(DesignR.string.snackbar_expense_deleted)
LaunchedEffect(viewModel) {
viewModel.eventFlow.collect { event ->
when (event) {
is EditorEvent.Saved -> onSaved(event.id, if (!event.isNew) null else if (event.offline) savedOffline else saved)
EditorEvent.Deleted -> onDeleted(deleted)
is EditorEvent.Saved -> onSaved(event.id, savedSnackbar(event.isNew, event.offline))
EditorEvent.Deleted -> onDeleted()
EditorEvent.Closed -> onClose()
EditorEvent.FocusDescription -> descriptionFocus.requestFocus()
}
@@ -155,6 +154,14 @@ fun ExpenseEditorScreen(
ExpenseEditorContent(state, viewModel, modifier, descriptionFocus)
}
/** The snackbar after a save: none after an edit; for a new expense the online or offline wording. */
@StringRes
internal fun savedSnackbar(isNew: Boolean, offline: Boolean): Int? = when {
!isNew -> null
offline -> DesignR.string.snackbar_expense_saved_offline
else -> DesignR.string.snackbar_expense_saved
}
/** The stateless S-7 layout: app bar, scrolling fields, the keypad, and the overlay of [ExpenseEditorState.overlay]. */
@Composable
fun ExpenseEditorContent(