From 3dda414ec2be1e5a555e1bc1215670cc2fe3810b Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 11:44:07 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/feature/account/build.gradle.kts | 2 + .../feature/account/AccountDetailScreen.kt | 24 ++++--- .../feature/account/AccountEditorScreen.kt | 38 +++++----- .../hagstedt/kvitt/feature/account/Errors.kt | 33 ++++----- .../kvitt/feature/home/SyncStatusScreen.kt | 4 +- .../kvitt/feature/onboarding/PairingScreen.kt | 12 ++-- .../feature/onboarding/ProfileStepScreen.kt | 8 ++- .../kvitt/feature/onboarding/WaitingScreen.kt | 9 +-- .../feature/settings/about/AboutScreen.kt | 14 ++-- .../categories/CategoryEditorScreen.kt | 14 ++-- .../settings/categories/CategoryListScreen.kt | 13 ++-- .../kvitt/feature/settings/common/Messages.kt | 69 +++++-------------- .../feature/settings/devices/DevicesScreen.kt | 14 ++-- .../feature/settings/hub/SettingsHubScreen.kt | 6 +- .../feature/settings/invites/InvitesScreen.kt | 15 ++-- .../feature/settings/profile/ProfileScreen.kt | 15 ++-- .../recurring/RecurringEditorScreen.kt | 14 ++-- .../settings/recurring/RecurringListScreen.kt | 6 +- .../feature/settle/SettlementDetailScreen.kt | 3 +- .../settle/SettlementDetailViewModel.kt | 11 ++- .../settle/SettlementDetailViewModelTest.kt | 10 ++- .../settle/screenshot/SettleScreenshotTest.kt | 3 +- 22 files changed, 177 insertions(+), 160 deletions(-) diff --git a/android/feature/account/build.gradle.kts b/android/feature/account/build.gradle.kts index e65ae87..6ad6695 100644 --- a/android/feature/account/build.gradle.kts +++ b/android/feature/account/build.gradle.kts @@ -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) diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt index 549ea67..bf88b99 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt @@ -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(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() } diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt index f364827..cbba71d 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt @@ -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() } diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt index 88bdc66..bc24608 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt @@ -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 +} diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt index ef06308..90b410d 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt @@ -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() } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt index 5834d3b..dff9cd5 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt @@ -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() } } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt index a5df195..7fb8ab4 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt @@ -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() } } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt index f9a7fa4..e54bd7b 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt @@ -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() } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt index 055550c..76ff25a 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt index 6c6e66d..1e35d4a 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt @@ -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( diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt index a7cdc81..701dec8 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt @@ -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() diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt index 3daac26..bb73779 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt @@ -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() } } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt index 5f18bf8..400d8a5 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt index b2a5ee8..98b7636 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt index bb49864..456a88b 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt index 541f205..637a4fb 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt index 34442df..69951c3 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt @@ -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) { diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt index 26c77d7..ae5bb29 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt @@ -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 diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt index 9e3ff12..09123d2 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt @@ -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) } diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt index e1b7edb..d686e76 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModel.kt @@ -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 = 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(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) } diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt index e331ee1..857ad77 100644 --- a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt @@ -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 } diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt index 31a01f9..a786f1b 100644 --- a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt @@ -78,6 +78,7 @@ private val cases: Map = 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) {