M8: app snackbars through showKvittSnackbar; account-archived undo with common_undo

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 14:28:02 +02:00
parent cb832a7cac
commit 3d9eea8235
2 changed files with 35 additions and 17 deletions
@@ -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<SessionState?> = 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) }
}
}
@@ -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<Any> = 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)
},
)
}