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