From 3d9eea8235e70c93af3d64fa283385452c46e33c Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 14:28:02 +0200 Subject: [PATCH] M8: app snackbars through showKvittSnackbar; account-archived undo with common_undo Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../kotlin/se/hagstedt/kvitt/AppViewModel.kt | 8 ++++ .../kotlin/se/hagstedt/kvitt/nav/KvittApp.kt | 44 ++++++++++++------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt index bc58691..8c80073 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import se.hagstedt.kvitt.link.AppLink import se.hagstedt.kvitt.link.OpenTarget +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.SessionRepository import se.hagstedt.kvitt.model.SessionState import se.hagstedt.kvitt.model.SyncRepository @@ -22,6 +24,7 @@ import javax.inject.Inject class AppViewModel @Inject constructor( session: SessionRepository, private val sync: SyncRepository, + private val accounts: AccountRepository, ) : ViewModel() { /** Null until the first emission (the shell shows the empty themed screen meanwhile). */ val session: StateFlow = session.session.stateIn(viewModelScope, SharingStarted.Eagerly, null) @@ -65,4 +68,9 @@ class AppViewModel @Inject constructor( fun notificationsAsked() { askNotificationsState.value = false } + + /** "Ångra" on the archived snackbar of S-5 (design-spec S-5). */ + fun undoArchive(id: AccountId) { + viewModelScope.launch { accounts.setArchived(id, false) } + } } diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt index 2e45560..0c4c835 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt @@ -3,6 +3,8 @@ package se.hagstedt.kvitt.nav import android.Manifest import android.app.Activity import android.content.pm.PackageManager +import android.content.res.Resources +import androidx.annotation.StringRes import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult @@ -25,6 +27,7 @@ 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.stringResource import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder @@ -43,6 +46,8 @@ import se.hagstedt.kvitt.design.component.KvittClock import se.hagstedt.kvitt.design.component.KvittDestination import se.hagstedt.kvitt.design.component.KvittHousehold import se.hagstedt.kvitt.design.component.KvittSnackbar +import se.hagstedt.kvitt.design.component.KvittSnackbarVisuals +import se.hagstedt.kvitt.design.component.showKvittSnackbar import se.hagstedt.kvitt.design.component.LocalAvatarPictures import se.hagstedt.kvitt.design.component.LocalKvittClock import se.hagstedt.kvitt.design.component.LocalKvittHousehold @@ -130,7 +135,8 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck val start = remember { startRoute(session) } val entry by nav.currentBackStackEntryAsState() val route = entry?.destination?.route - val shell = Shell(nav, snackbar, scope) + val resources = LocalResources.current + val shell = Shell(nav, snackbar, scope, resources) // The session gate: revocation (S-18) and a lost pairing (S-1 "Pair this phone again") replace the whole stack. LaunchedEffect(session) { @@ -157,13 +163,16 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck NavHost(nav, startDestination = start) { onboarding(viewModel, shell) topLevel(session, shell) - accountsAndExpenses(shell) + accountsAndExpenses(viewModel, shell) settings(shell, updateCheckAvailable) } // Home shows result snackbars in its own scaffold; every other screen gets them here. if (route != Routes.HOME) { SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().imePadding()) { data -> - KvittSnackbar(data.visuals.message, Modifier.padding(KvittTheme.spacing.s4), data.visuals.actionLabel, { data.performAction() }) + val visuals = data.visuals + val message = if (visuals is KvittSnackbarVisuals) stringResource(visuals.text, *visuals.args.toTypedArray()) else visuals.message + val action = if (visuals is KvittSnackbarVisuals) visuals.action?.let { stringResource(it) } else visuals.actionLabel + KvittSnackbar(message, Modifier.padding(KvittTheme.spacing.s4), action, { data.performAction() }) } } } @@ -176,7 +185,7 @@ private fun startRoute(session: SessionState): String = when (session) { } /** Navigation helpers shared by the graph sections. */ -private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState, val scope: CoroutineScope) { +private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState, val scope: CoroutineScope, val resources: Resources) { fun replaceAll(route: String) = nav.navigate(route) { popUpTo(nav.graph.id) { inclusive = true } launchSingleTop = true @@ -201,11 +210,16 @@ private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState, nav.popBackStack() } - fun show(message: String?, action: String? = null, onAction: () -> Unit = {}) { + /** A snackbar text a feature resolved itself (S-7, S-8 hand over a String). */ + fun show(message: String?) { if (message == null) return + scope.launch { snackbar.showSnackbar(message, withDismissAction = false) } + } + + /** `:app`'s own snackbars, resolved from resources while they show (language-safe, `showKvittSnackbar`). */ + fun show(@StringRes text: Int, args: List = emptyList(), @StringRes action: Int? = null, onAction: () -> Unit = {}) { scope.launch { - val result = snackbar.showSnackbar(message, actionLabel = action, withDismissAction = false) - if (result == SnackbarResult.ActionPerformed) onAction() + if (snackbar.showKvittSnackbar(resources, text, args, action) == SnackbarResult.ActionPerformed) onAction() } } } @@ -279,9 +293,8 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) { } } -private fun NavGraphBuilder.accountsAndExpenses(shell: Shell) { +private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell: Shell) { composable(Routes.ACCOUNT, arguments = Routes.accountArgs) { - val resources = LocalResources.current AccountDetailScreen( onBack = shell::back, onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, @@ -289,11 +302,10 @@ private fun NavGraphBuilder.accountsAndExpenses(shell: Shell) { onAddExpense = { shell.nav.navigate(Routes.expenseEditor(account = it)) }, onSettle = { shell.nav.navigate(Routes.settle(account = it)) }, onEdit = { shell.nav.navigate(Routes.accountEditor(it)) }, - onArchived = { _, name -> + onArchived = { id, name -> shell.nav.popBackStack(Routes.HOME, inclusive = false) - // design-spec S-5: "Vardag arkiverades" with "Ångra" on Home. The undo label (common_undo) is not in - // :core:design yet, so the snackbar has no action until it is (M8 report, contract request). - shell.show(resources.getString(AccountR.string.account_archived_snackbar, name)) + // design-spec S-5: "Vardag arkiverades" with "Ångra" on Home. + shell.show(AccountR.string.account_archived_snackbar, listOf(name), DesignR.string.common_undo) { viewModel.undoArchive(id) } }, onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) }, ) @@ -333,23 +345,21 @@ private fun NavGraphBuilder.accountsAndExpenses(shell: Shell) { ) } composable(Routes.SETTLE, arguments = Routes.settleArgs) { - val resources = LocalResources.current SettleScreen( onClose = shell::back, onSaved = { shell.back() - shell.show(resources.getString(DesignR.string.snackbar_payment_saved)) + shell.show(DesignR.string.snackbar_payment_saved) }, ) } composable(Routes.SETTLEMENT, arguments = Routes.settlementArgs) { - val resources = LocalResources.current SettlementDetailScreen( onBack = shell::back, onEdit = { shell.nav.navigate(Routes.settle(editing = it)) }, onDeleted = { shell.back() - shell.show(resources.getString(DesignR.string.snackbar_payment_deleted)) + shell.show(DesignR.string.snackbar_payment_deleted) }, ) }