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
@@ -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
}