M7g: feature cleanups after the design-system fix pass

Resource-backed snackbars (showKvittSnackbar) in account, onboarding and
settings; SettingsScaffold replaced by KvittScaffold + SettingsMessageEffect.
KvittButton(loading) for S-6 saving and S-16 Synka nu. S-10 missing state
disables Synka nu offline (connectivity in the ViewModel, new
detail_missing_offline goldens). S-6 system back asks to discard unsaved
changes.

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 11:44:07 +02:00
parent f4dc10c7ae
commit 3dda414ec2
22 changed files with 177 additions and 160 deletions
+2
View File
@@ -10,6 +10,8 @@ roborazzi {
}
dependencies {
// BackHandler: system back on S-6 with unsaved changes asks to discard (design-spec S-6).
implementation(libs.androidx.activity.compose)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.robolectric)
@@ -10,7 +10,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
@@ -22,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
@@ -44,6 +44,7 @@ import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.AccountId
@@ -273,16 +274,17 @@ private fun DeleteDialog(name: String, actions: AccountDetailActions) {
/** Shows [message] once (4 s, 10 s with an action, design-spec §3.11); "Försök igen" repeats the failed action. */
@Composable
private fun MessageEffect(message: AccountMessage, host: SnackbarHostState, actions: AccountDetailActions) {
val text = when (message) {
is AccountMessage.CouldNotReach -> stringResource(DesignR.string.common_could_not_reach_server)
is AccountMessage.Restored -> stringResource(R.string.account_unarchived_snackbar, message.name)
AccountMessage.DeleteBlocked -> stringResource(R.string.account_delete_blocked)
AccountMessage.NeedsConnection -> stringResource(DesignR.string.common_needs_connection)
is AccountMessage.Failed -> errorText(message.code)
}
val action = if (message is AccountMessage.CouldNotReach) stringResource(DesignR.string.common_retry) else null
LaunchedEffect(message, text, action) {
val result = host.showSnackbar(text, action, duration = if (action != null) SnackbarDuration.Long else SnackbarDuration.Short)
val resources = LocalResources.current
LaunchedEffect(message) {
val (text, args) = when (message) {
is AccountMessage.CouldNotReach -> DesignR.string.common_could_not_reach_server to emptyList()
is AccountMessage.Restored -> R.string.account_unarchived_snackbar to listOf<Any>(message.name)
AccountMessage.DeleteBlocked -> R.string.account_delete_blocked to emptyList()
AccountMessage.NeedsConnection -> DesignR.string.common_needs_connection to emptyList()
is AccountMessage.Failed -> errorText(message.code) to emptyList()
}
val action = if (message is AccountMessage.CouldNotReach) DesignR.string.common_retry else null
val result = host.showKvittSnackbar(resources, text, args, action)
if (result == SnackbarResult.ActionPerformed && message is AccountMessage.CouldNotReach) actions.onRetry(message.retry)
actions.onMessageShown()
}
@@ -1,5 +1,6 @@
package se.hagstedt.kvitt.feature.account
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -10,12 +11,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
@@ -29,6 +27,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -54,6 +53,7 @@ import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -90,6 +90,8 @@ fun AccountEditorScreen(
viewModel.savedHandled()
onSaved(saved)
}
// System back with unsaved changes asks first, as the close button does (design-spec S-6).
BackHandler(enabled = ui.dirty && !ui.discardDialog) { viewModel.requestClose() }
val actions = AccountEditorActions(
onClose = { if (viewModel.requestClose()) onClose() },
onName = viewModel::onName,
@@ -142,13 +144,16 @@ fun AccountEditorContent(ui: AccountEditorUi, actions: AccountEditorActions, mod
}
}
/** "Spara" (small filled); a progress indicator in its place while saving (design-spec S-6). */
/** "Spara" (small filled); its in-button progress form while saving (design-spec S-6). */
@Composable
private fun SaveAction(ui: AccountEditorUi, actions: AccountEditorActions) {
Row(Modifier.padding(end = KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
if (ui.saving) CircularProgressIndicator(Modifier.size(KvittDimens.iconButton), strokeWidth = KvittDimens.placeholderBorder)
KvittButton(stringResource(DesignR.string.common_save), actions.onSave, enabled = ui.canSave)
}
KvittButton(
stringResource(DesignR.string.common_save),
actions.onSave,
Modifier.padding(end = KvittTheme.spacing.s2),
enabled = ui.canSave || ui.saving,
loading = ui.saving,
)
}
@Composable
@@ -293,14 +298,15 @@ private const val PREVIEW_ORE = BP_TOTAL.toLong()
@Composable
private fun EditorMessageEffect(message: EditorMessage, host: SnackbarHostState, actions: AccountEditorActions) {
val text = when (message) {
EditorMessage.CouldNotReach -> stringResource(DesignR.string.common_could_not_reach_server)
EditorMessage.NeedsConnection -> stringResource(DesignR.string.common_needs_connection)
is EditorMessage.Failed -> errorText(message.code)
}
val action = if (message == EditorMessage.CouldNotReach) stringResource(DesignR.string.common_retry) else null
LaunchedEffect(message, text, action) {
val result = host.showSnackbar(text, action, duration = if (action != null) SnackbarDuration.Long else SnackbarDuration.Short)
val resources = LocalResources.current
LaunchedEffect(message) {
val text = when (message) {
EditorMessage.CouldNotReach -> DesignR.string.common_could_not_reach_server
EditorMessage.NeedsConnection -> DesignR.string.common_needs_connection
is EditorMessage.Failed -> errorText(message.code)
}
val action = if (message == EditorMessage.CouldNotReach) DesignR.string.common_retry else null
val result = host.showKvittSnackbar(resources, text, action = action)
if (result == SnackbarResult.ActionPerformed) actions.onSave()
actions.onMessageShown()
}
@@ -1,24 +1,19 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import androidx.annotation.StringRes
import se.hagstedt.kvitt.core.design.R as DesignR
/** The snackbar text of an `OnlineResult.Failed` code without a field at fault (§14.3.2, strings.csv `error_*`). */
@Composable
@ReadOnlyComposable
internal fun errorText(code: String): String = stringResource(
when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
"household_incomplete" -> DesignR.string.error_household_incomplete
"not_found" -> DesignR.string.error_not_found
"forbidden" -> DesignR.string.error_forbidden
"rate_limited" -> DesignR.string.error_rate_limited
"protocol_unsupported" -> DesignR.string.error_app_too_old
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
"validation_failed" -> DesignR.string.error_validation
"account_not_empty" -> R.string.account_delete_blocked
else -> DesignR.string.error_generic_invalid
},
)
@StringRes
internal fun errorText(code: String): Int = when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
"household_incomplete" -> DesignR.string.error_household_incomplete
"not_found" -> DesignR.string.error_not_found
"forbidden" -> DesignR.string.error_forbidden
"rate_limited" -> DesignR.string.error_rate_limited
"protocol_unsupported" -> DesignR.string.error_app_too_old
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
"validation_failed" -> DesignR.string.error_validation
"account_not_empty" -> R.string.account_delete_blocked
else -> DesignR.string.error_generic_invalid
}
@@ -134,6 +134,7 @@ private fun SyncStatusList(ui: SyncStatusUi.Ready, actions: SyncStatusActions, p
if (summary.clockSkew) item(key = "clock") { ClockBanner(actions.onOpenDateSettings) }
item(key = "status") { StatusCard(ui) }
item(key = "sync-now") {
val syncing = ui.online && summary.running
Column {
KvittButton(
stringResource(R.string.home_sync_status_sync_now),
@@ -141,7 +142,8 @@ private fun SyncStatusList(ui: SyncStatusUi.Ready, actions: SyncStatusActions, p
Modifier.fillMaxWidth(),
size = ButtonSize.Medium,
icon = KvittIcon.Sync,
enabled = ui.canSyncNow,
enabled = ui.canSyncNow || syncing,
loading = syncing,
)
if (!ui.online) NeedsConnectionHint()
}
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
@@ -46,6 +46,7 @@ import se.hagstedt.kvitt.design.component.InviteCodeField
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -113,14 +114,15 @@ fun PairingContent(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val snackbarText = when (state.error) {
PairingError.AppTooOld -> stringResource(DesignR.string.error_app_too_old)
PairingError.Server -> stringResource(DesignR.string.error_server)
PairingError.Validation -> stringResource(DesignR.string.error_validation)
PairingError.AppTooOld -> DesignR.string.error_app_too_old
PairingError.Server -> DesignR.string.error_server
PairingError.Validation -> DesignR.string.error_validation
else -> null
}
val resources = LocalResources.current
LaunchedEffect(snackbarText) {
if (snackbarText != null) {
snackbarHostState.showSnackbar(snackbarText, duration = SnackbarDuration.Short)
snackbarHostState.showKvittSnackbar(resources, snackbarText)
actions.onErrorShown()
}
}
@@ -22,7 +22,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -33,6 +32,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -60,6 +60,7 @@ import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -130,10 +131,11 @@ fun ProfileStepContent(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val message = state.message?.let { stringResource(it.text) }
val message = state.message?.text
val resources = LocalResources.current
LaunchedEffect(message) {
if (message != null) {
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
snackbarHostState.showKvittSnackbar(resources, message)
actions.onMessageShown()
}
}
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
@@ -22,6 +21,7 @@ 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.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -43,6 +43,7 @@ import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import kotlin.time.Instant
@@ -83,12 +84,12 @@ fun WaitingContent(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val error = state.loadError?.let {
stringResource(if (it == "network") DesignR.string.common_could_not_reach_server else DesignR.string.error_server)
if (it == "network") DesignR.string.common_could_not_reach_server else DesignR.string.error_server
}
val retry = stringResource(DesignR.string.common_retry)
val resources = LocalResources.current
LaunchedEffect(error) {
if (error != null) {
val result = snackbarHostState.showSnackbar(error, actionLabel = retry, duration = SnackbarDuration.Long)
val result = snackbarHostState.showKvittSnackbar(resources, error, action = DesignR.string.common_retry)
actions.onErrorShown()
if (result == SnackbarResult.ActionPerformed) actions.onRetry()
}
@@ -15,9 +15,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.clip
@@ -35,6 +37,7 @@ import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.SegmentedGroup
@@ -44,7 +47,7 @@ import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -76,13 +79,14 @@ class AboutActions(
/** Stateless S-22. */
@Composable
fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAvailable: Boolean, modifier: Modifier = Modifier) {
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown)
KvittScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_about_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
message = state?.message,
onMessageShown = actions.onMessageShown,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
val about = state.about
Column(
Modifier
@@ -11,10 +11,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
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.res.pluralStringResource
@@ -30,6 +32,7 @@ import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
@@ -39,8 +42,8 @@ import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.SaveAction
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -100,7 +103,9 @@ class CategoryEditorActions(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CategoryEditorContent(state: CategoryEditorState?, actions: CategoryEditorActions, modifier: Modifier = Modifier) {
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown)
KvittScaffold(
modifier.fillMaxSize(),
topBar = {
val title = if (state?.isNew != false) R.string.settings_category_editor_new else R.string.settings_category_editor_edit
@@ -108,10 +113,9 @@ fun CategoryEditorContent(state: CategoryEditorState?, actions: CategoryEditorAc
SaveAction(enabled = state?.canSave == true, onClick = actions.onSave)
}
},
message = state?.message,
onMessageShown = actions.onMessageShown,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
val defaultName = state.defaultKey?.let { stringResource(defaultCategoryName(it)) }
val shownName = state.name.trim().ifEmpty { defaultName ?: "" }
Column(
@@ -19,6 +19,7 @@ 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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -52,6 +53,7 @@ import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.CollapsibleRow
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.SegmentedGroup
@@ -61,7 +63,7 @@ import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
@@ -118,7 +120,9 @@ class CategoryListActions(
/** Stateless S-15 list. */
@Composable
fun CategoryListContent(state: CategoryListState?, actions: CategoryListActions, modifier: Modifier = Modifier) {
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown)
KvittScaffold(
modifier.fillMaxSize(),
topBar = {
KvittTopAppBar(stringResource(R.string.settings_categories_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) {
@@ -127,10 +131,9 @@ fun CategoryListContent(state: CategoryListState?, actions: CategoryListActions,
}
}
},
message = state?.message,
onMessageShown = actions.onMessageShown,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
Column(
Modifier
.fillMaxSize()
@@ -1,24 +1,14 @@
package se.hagstedt.kvitt.feature.settings.common
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import kotlinx.coroutines.delay
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittSnackbar
import se.hagstedt.kvitt.design.component.snackbarMillis
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalResources
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.feature.settings.R
@@ -77,42 +67,19 @@ fun failureText(code: String): Int = when (code) {
fun failureMessage(code: String, canRetry: Boolean = true): UiMessage = UiMessage(failureText(code), retry = canRetry && code == NETWORK)
/**
* The settings screens' scaffold: [KvittScaffold] plus the one-shot snackbar of [message] (design-spec §3.11:
* `inverse-surface`, 4 s, 10 s with "Försök igen"), drawn at the bottom above the bars. The text is resolved while
* shown, so it follows the app language. [onMessageShown] when it times out or its action is used.
* Shows [message] once in [host] (design-spec §3.11: 4 s, 10 s with "Försök igen") through [showKvittSnackbar], so its
* text follows the app language while it shows. [onMessageShown] when it times out or its action is used; the action
* then runs [onRetry].
*/
@Composable
fun SettingsScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
message: UiMessage? = null,
onMessageShown: () -> Unit = {},
onRetry: () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
Box(modifier.fillMaxSize()) {
KvittScaffold(Modifier.fillMaxSize(), topBar = topBar, bottomBar = bottomBar, floatingActionButton = floatingActionButton, content = content)
if (message != null) {
LaunchedEffect(message.id) {
delay(snackbarMillis(message.retry))
onMessageShown()
}
val text = stringResource(message.text, *message.args.toTypedArray())
KvittSnackbar(
text,
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(KvittTheme.spacing.s4)
.semantics { liveRegion = LiveRegionMode.Polite },
action = if (message.retry) stringResource(DesignR.string.common_retry) else null,
onAction = {
onMessageShown()
onRetry()
},
)
}
fun SettingsMessageEffect(message: UiMessage?, host: SnackbarHostState, onMessageShown: () -> Unit, onRetry: () -> Unit = {}) {
val resources = LocalResources.current
val shown by rememberUpdatedState(onMessageShown)
val retry by rememberUpdatedState(onRetry)
LaunchedEffect(message?.id) {
if (message == null) return@LaunchedEffect
val result = host.showKvittSnackbar(resources, message.text, message.args, if (message.retry) DesignR.string.common_retry else null)
shown()
if (result == SnackbarResult.ActionPerformed) retry()
}
}
@@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -33,6 +35,7 @@ import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
@@ -48,7 +51,7 @@ import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.CentredStatus
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.User
@@ -73,13 +76,14 @@ class DevicesActions(
/** Stateless S-20. */
@Composable
fun DevicesContent(state: DevicesState?, actions: DevicesActions, modifier: Modifier = Modifier) {
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown)
KvittScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_devices_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
message = state?.message,
onMessageShown = actions.onMessageShown,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
@@ -29,6 +29,7 @@ import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittDivider
import se.hagstedt.kvitt.design.component.KvittNavigationBar
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
@@ -38,7 +39,6 @@ import se.hagstedt.kvitt.design.component.syncLineText
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.User
@@ -77,12 +77,12 @@ class SettingsHubActions(
/** Stateless S-13; [state] null while the session loads (nothing but the bars). */
@Composable
fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, modifier: Modifier = Modifier) {
SettingsScaffold(
KvittScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_title)) },
bottomBar = { KvittNavigationBar(KvittDestination.Settings, actions.onNavigate) },
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
@@ -18,9 +18,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,6 +46,7 @@ import se.hagstedt.kvitt.design.component.InviteCodeDisplay
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.ProvideHousehold
@@ -58,8 +61,8 @@ import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteKind
@@ -121,17 +124,17 @@ class InvitesActions(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun InvitesContent(state: InvitesState?, actions: InvitesActions, modifier: Modifier = Modifier) {
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown, actions.onRetry)
KvittScaffold(
modifier.fillMaxSize(),
topBar = {
val title = if (state != null && state.partner == null) stringResource(DesignR.string.common_invite_partner) else stringResource(R.string.settings_invites_title_device)
KvittTopAppBar(title, navigation = TopBarNavigation.Back, onNavigation = actions.onBack)
},
message = state?.message,
onMessageShown = actions.onMessageShown,
onRetry = actions.onRetry,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
@@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -33,6 +35,7 @@ import se.hagstedt.kvitt.design.component.AvatarShape
import se.hagstedt.kvitt.design.component.AvatarState
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.PersonAvatar
@@ -41,11 +44,11 @@ import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.feature.settings.common.SaveAction
import java.io.IOException
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
/** S-14 Profile. [onBack] after a saved name and on the back arrow. */
@Composable
@@ -105,18 +108,18 @@ class ProfileActions(
/** Stateless S-14. */
@Composable
fun ProfileContent(state: ProfileState?, actions: ProfileActions, modifier: Modifier = Modifier) {
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown, actions.onRetry)
KvittScaffold(
modifier.fillMaxSize(),
topBar = {
KvittTopAppBar(stringResource(R.string.settings_profile), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) {
SaveAction(enabled = state?.canSave == true, onClick = actions.onSave)
}
},
message = state?.message,
onMessageShown = actions.onMessageShown,
onRetry = actions.onRetry,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
@@ -19,6 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -57,6 +58,7 @@ import se.hagstedt.kvitt.design.component.ExactSplitEditor
import se.hagstedt.kvitt.design.component.FullSplitPicker
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NotesField
@@ -79,9 +81,9 @@ import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.feature.settings.common.SaveAction
import se.hagstedt.kvitt.feature.settings.common.SettingsMessageEffect
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.SplitMode
@@ -165,7 +167,9 @@ class RecurringEditorActions(
@Composable
fun RecurringEditorContent(state: RecurringEditorState?, actions: RecurringEditorActions, modifier: Modifier = Modifier) {
val keypad = state != null && state.form.focus != EditorFocus.None
SettingsScaffold(
val snackbar = remember { SnackbarHostState() }
SettingsMessageEffect(state?.message, snackbar, actions.onMessageShown, actions.onRetry)
KvittScaffold(
modifier.fillMaxSize(),
topBar = {
val title = if (state?.isNew != false) R.string.settings_recurring_editor_new else R.string.settings_recurring_editor_edit
@@ -176,11 +180,9 @@ fun RecurringEditorContent(state: RecurringEditorState?, actions: RecurringEdito
bottomBar = {
if (keypad) AmountKeypad(actions.onKey, Modifier.navigationBarsPadding(), compact = state.form.splitOpen)
},
message = state?.message,
onMessageShown = actions.onMessageShown,
onRetry = actions.onRetry,
snackbarHostState = snackbar,
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
ProvideHousehold(state.options.me, state.options.partner) {
EditorBody(state, actions, Modifier.padding(padding))
when (state.sheet) {
@@ -38,6 +38,7 @@ import se.hagstedt.kvitt.design.component.BadgeKind
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.LocalKvittHousehold
@@ -53,7 +54,6 @@ import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -77,14 +77,14 @@ class RecurringListActions(val onBack: () -> Unit = {}, val onOpen: (TemplateId)
@Composable
fun RecurringListContent(state: RecurringListState?, actions: RecurringListActions, modifier: Modifier = Modifier) {
val empty = state != null && state.rows.isEmpty()
SettingsScaffold(
KvittScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_recurring_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
floatingActionButton = {
if (state != null && !empty) AddExpenseFab(actions.onNew, text = stringResource(R.string.settings_recurring_new), enabled = state.online)
},
) { padding ->
if (state == null) return@SettingsScaffold
if (state == null) return@KvittScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
@@ -123,7 +123,8 @@ fun SettlementDetailContent(
action = stringResource(R.string.settle_detail_sync_now),
actionIcon = KvittIcon.Sync,
actionKind = ButtonKind.Tonal,
onAction = actions::onSyncNow,
actionEnabled = state.online,
onAction = { if (state.online) actions.onSyncNow() },
)
else -> DetailBody(state, detail)
}
@@ -16,6 +16,7 @@ import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.SettlementDetailState
import se.hagstedt.kvitt.model.SettlementId
@@ -25,7 +26,8 @@ import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.User
/**
* Everything S-10 shows. [detail] null after loading is the missing state (the S-8 missing layout, design-spec S-10).
* Everything S-10 shows. [detail] null after loading is the missing state (the S-8 missing layout, design-spec S-10);
* its "Synka nu" needs [online].
*/
data class SettlementDetailUiState(
val loading: Boolean = true,
@@ -35,6 +37,7 @@ data class SettlementDetailUiState(
val accounts: List<Account> = emptyList(),
val confirmDelete: Boolean = false,
val deletedByMe: Boolean = false,
val online: Boolean = true,
) {
/** Design-spec §2.4: an account's colour is its position in `AccountRepository.all()`. */
fun colorIndex(id: AccountId): Int = accounts.indexOfFirst { it.id == id }.coerceAtLeast(0)
@@ -61,6 +64,7 @@ class SettlementDetailViewModel @javax.inject.Inject constructor(
private val settlements: SettlementRepository,
accounts: AccountRepository,
home: HomeRepository,
connectivity: ConnectivityRepository,
private val sync: SyncRepository,
) : ViewModel(), SettlementDetailActions {
val id: SettlementId? = savedStateHandle.get<String>(SettleArgs.SETTLEMENT_ID)?.let(::SettlementId)
@@ -70,9 +74,10 @@ class SettlementDetailViewModel @javax.inject.Inject constructor(
id?.let { settlements.detail(it) } ?: flowOf(null),
accounts.all(),
home.home(),
connectivity.online,
local,
) { detail, all, h, l ->
l.copy(loading = false, detail = detail, accounts = all, me = h.me, partner = h.partner)
) { detail, all, h, online, l ->
l.copy(loading = false, detail = detail, accounts = all, me = h.me, partner = h.partner, online = online)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), SettlementDetailUiState())
override fun onDelete() = local.update { it.copy(confirmDelete = true) }
@@ -30,7 +30,7 @@ class SettlementDetailViewModelTest {
fun reset() = Dispatchers.resetMain()
private fun vm(id: String, repos: FakeRepositories = FakeRepositories(SettleScenarios.detail)) =
SettlementDetailViewModel(SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to id)), repos.settlements, repos.accounts, repos.home, repos.sync)
SettlementDetailViewModel(SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to id)), repos.settlements, repos.accounts, repos.home, repos.connectivity, repos.sync)
@Test
fun loadedShowsTheSettlementAndItsActions() = runTest {
@@ -48,6 +48,14 @@ class SettlementDetailViewModelTest {
assertFalse(s.actions)
}
@Test
fun missingOfflineCarriesTheConnectivityForSyncNow() = runTest {
assertTrue(vm(SettleScenarios.missing.value).state.first { !it.loading }.online)
val offline = vm(SettleScenarios.missing.value, FakeRepositories(SettleScenarios.detailOffline)).state.first { !it.loading }
assertNull(offline.detail)
assertFalse(offline.online)
}
@Test
fun deletedHasTheDeletionLineAndNoActions() = runTest {
val s = vm(SettleScenarios.deleted.value).state.first { !it.loading }
@@ -78,6 +78,7 @@ private val cases: Map<String, Case> = linkedMapOf(
"detail_offline" to Detail(SettleScenarios.detailOffline, SettleScenarios.split.value),
"detail_delete_dialog" to Detail(SettleScenarios.detail, SettleScenarios.swish.value) { onDelete() },
"detail_missing" to Detail(SettleScenarios.detail, SettleScenarios.missing.value),
"detail_missing_offline" to Detail(SettleScenarios.detailOffline, SettleScenarios.missing.value),
"detail_long" to Detail(SettleScenarios.longDetail, SettleScenarios.long.value, height = longHeight),
)
@@ -105,7 +106,7 @@ class SettleScreenshotTest(private val name: String) {
is Detail -> {
val repos = FakeRepositories(case.scenario)
val vm = SettlementDetailViewModel(
SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to case.id)), repos.settlements, repos.accounts, repos.home, repos.sync,
SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to case.id)), repos.settlements, repos.accounts, repos.home, repos.connectivity, repos.sync,
)
vm.apply(case.steps)
ScreenSnapshots.snap(compose, name, case.height, case.largeFont) {