M7b: feature home and account (S-4, S-5, S-6, S-16, S-17)

:feature:home: Home with balance hero, sync pill, accounts, archived section,
empty and first-sync states, update banner and "Tillåt installation" dialog;
sync status with pending rows, rejections of every §14.4.6 reason and the
clock-skew banner. :feature:account: account detail with header, ledger by
month, overflow menu, archive/unarchive/delete and their errors; account
editor with name, percent fields (two decimals), slider, presets, preview,
name_taken/validation errors and offline state. Fake scenarios in
fakes/home and fakes/account; full-screen Roborazzi goldens for every state
in sv/en × light/dark (200 % for long states); ViewModel unit tests.

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 10:49:31 +02:00
parent 7380e50ae3
commit 0e1d99df93
26 changed files with 3027 additions and 28 deletions
@@ -0,0 +1,289 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.derivedStateOf
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.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AccountBalanceHeader
import se.hagstedt.kvitt.design.component.AccountHeaderWords
import se.hagstedt.kvitt.design.component.AddExpenseFab
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittMenuContent
import se.hagstedt.kvitt.design.component.KvittMenuItem
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LedgerMonthGroup
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.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.core.design.R as DesignR
/** Navigation and actions of the account screen; `:app` wires the navigation lambdas (D-017). */
data class AccountDetailActions(
val onBack: () -> Unit = {},
val onOpenExpense: (ExpenseId) -> Unit = {},
val onOpenSettlement: (SettlementId) -> Unit = {},
val onAddExpense: (AccountId) -> Unit = {},
val onSettle: (AccountId) -> Unit = {},
val onEdit: (AccountId) -> Unit = {},
val onMenu: (Boolean) -> Unit = {},
val onArchive: () -> Unit = {},
val onUnarchive: () -> Unit = {},
val onAskDelete: () -> Unit = {},
val onConfirmDelete: () -> Unit = {},
val onDismissDelete: () -> Unit = {},
val onRetry: (AccountAction) -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/**
* Account detail (S-5). The account id is the navigation argument [AccountDetailViewModel.ARG_ACCOUNT_ID].
* [onArchived] is called after archiving with the account's name: `:app` returns to Home and shows
* "Vardag arkiverades" with "Ångra" there (design-spec S-5). [onDeleted] after deleting, or when the account is gone.
*/
@Composable
fun AccountDetailScreen(
onBack: () -> Unit,
onOpenExpense: (ExpenseId) -> Unit,
onOpenSettlement: (SettlementId) -> Unit,
onAddExpense: (AccountId) -> Unit,
onSettle: (AccountId) -> Unit,
onEdit: (AccountId) -> Unit,
onArchived: (AccountId, String) -> Unit,
onDeleted: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountDetailViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val event = when (val current = ui) {
is AccountDetailUi.Ready -> current.event
is AccountDetailUi.Gone -> current.event
AccountDetailUi.Starting -> null
}
LaunchedEffect(event) {
when (event) {
is AccountEvent.Archived -> onArchived(viewModel.id, event.name)
AccountEvent.Deleted, AccountEvent.Gone -> onDeleted()
null -> return@LaunchedEffect
}
viewModel.eventHandled()
}
val actions = AccountDetailActions(
onBack = onBack,
onOpenExpense = onOpenExpense,
onOpenSettlement = onOpenSettlement,
onAddExpense = onAddExpense,
onSettle = onSettle,
onEdit = onEdit,
onMenu = viewModel::setMenuOpen,
onArchive = viewModel::archive,
onUnarchive = viewModel::unarchive,
onAskDelete = viewModel::askDelete,
onConfirmDelete = viewModel::confirmDelete,
onDismissDelete = viewModel::dismissDelete,
onRetry = viewModel::retry,
onMessageShown = viewModel::messageShown,
)
AccountDetailContent(ui, actions, modifier)
}
/** The stateless account screen, rendered by [AccountDetailScreen], previews and screenshot tests. */
@Composable
fun AccountDetailContent(
ui: AccountDetailUi,
actions: AccountDetailActions,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
val ready = ui as? AccountDetailUi.Ready
val snackbar = remember { SnackbarHostState() }
val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } }
val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } }
ready?.message?.let { message -> MessageEffect(message, snackbar, actions) }
KvittScaffold(
modifier = modifier,
topBar = {
KvittTopAppBar(
ready?.detail?.account?.name.orEmpty(),
navigation = TopBarNavigation.Back,
onNavigation = actions.onBack,
scrolled = scrolled,
actions = { if (ready != null) OverflowMenu(ready, actions) },
)
},
floatingActionButton = {
if (ready?.showFab == true) AddExpenseFab({ actions.onAddExpense(ready.detail.account.id) }, expanded = fabExpanded)
},
snackbarHostState = snackbar,
) { padding ->
if (ready == null) {
Box(Modifier.fillMaxSize().padding(padding))
} else {
ProvideHousehold(ready.me, ready.partner) { AccountList(ready, actions, padding, listState) }
if (ready.deleteDialog) DeleteDialog(ready.detail.account.name, actions)
}
}
}
@Composable
private fun AccountList(ui: AccountDetailUi.Ready, actions: AccountDetailActions, padding: PaddingValues, listState: LazyListState) {
val spacing = KvittTheme.spacing
val account = ui.detail.account
val words = AccountHeaderWords(
defaultSplit = defaultSplit(ui),
defaultSplitSpoken = stringResource(R.string.account_default_split_spoken, ratioSpoken(ui.me, viewerBp(ui), ui.partner)),
settleAccount = stringResource(R.string.account_settle_this),
unarchive = stringResource(R.string.account_unarchive),
)
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
LazyColumn(
Modifier.fillMaxSize().padding(padding),
state = listState,
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
) {
item(key = "header") {
AccountBalanceHeader(ui.detail, ui.colorIndex, ui.viewerPercent, words, { actions.onSettle(account.id) }, actions.onUnarchive)
}
if (ui.detail.months.isEmpty()) {
item(key = "empty") {
EmptyState(stringResource(R.string.account_empty_title), stringResource(R.string.account_empty_body, account.name))
}
}
ui.detail.months.forEach { month ->
item(key = month.period) {
LedgerMonthGroup(
month.period,
month.householdSpendOre,
month.rows,
typeLabel,
onExpense = { actions.onOpenExpense(it.id) },
onSettlement = { actions.onOpenSettlement(it.id) },
)
}
}
}
}
private fun viewerBp(ui: AccountDetailUi.Ready): Int = ui.detail.account.defaultShares[ui.me.id] ?: HALF_BP
/** "60/40 · Jacob och Anna": default shares in percent, viewer first (`account_default_split`). */
@Composable
@ReadOnlyComposable
private fun defaultSplit(ui: AccountDetailUi.Ready): String {
val language = kvittLanguage()
val bp = viewerBp(ui)
val ratio = stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(bp, language), MoneyFormatter.basisPoints(BP_TOTAL - bp, language))
val names = stringResource(DesignR.string.common_and, ui.me.displayName, ui.partner.displayName)
return stringResource(R.string.account_default_split, ratio, names)
}
internal const val BP_TOTAL = 10_000
/** `more_vert` with the S-5 menu; disabled items carry their reason as a second line (design-spec §3.11). */
@Composable
private fun OverflowMenu(ui: AccountDetailUi.Ready, actions: AccountDetailActions) {
val needsConnection = stringResource(DesignR.string.common_needs_connection)
val offlineReason = if (ui.online) null else needsConnection
val archived = ui.detail.account.archived
Box {
IconButton({ actions.onMenu(true) }) {
KvittIconImage(KvittIcon.MoreVert, stringResource(DesignR.string.common_more_options), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (ui.menuOpen) {
val offset = with(LocalDensity.current) { KvittTheme.spacing.touchTarget.roundToPx() }
Popup(
alignment = Alignment.TopEnd,
offset = IntOffset(0, offset),
onDismissRequest = { actions.onMenu(false) },
properties = PopupProperties(focusable = true),
) {
KvittMenuContent(
listOf(
KvittMenuItem(KvittIcon.Edit, stringResource(R.string.account_edit), ui.canEdit, offlineReason) {
actions.onMenu(false)
actions.onEdit(ui.detail.account.id)
},
if (archived) {
KvittMenuItem(KvittIcon.Unarchive, stringResource(R.string.account_unarchive), ui.canEdit, offlineReason, actions.onUnarchive)
} else {
KvittMenuItem(KvittIcon.Inventory2, stringResource(R.string.account_archive), ui.canEdit, offlineReason, actions.onArchive)
},
KvittMenuItem(
KvittIcon.Delete,
stringResource(R.string.account_delete),
ui.canDelete,
offlineReason ?: stringResource(R.string.account_delete_blocked).takeIf { !ui.detail.deletable },
actions.onAskDelete,
),
),
)
}
}
}
}
@Composable
private fun DeleteDialog(name: String, actions: AccountDetailActions) {
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(R.string.account_delete_confirm_title, name),
body = stringResource(R.string.account_delete_confirm_body),
confirm = stringResource(DesignR.string.common_delete),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = actions.onConfirmDelete,
onDismiss = actions.onDismissDelete,
destructive = true,
)
}
/** 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)
if (result == SnackbarResult.ActionPerformed && message is AccountMessage.CouldNotReach) actions.onRetry(message.retry)
actions.onMessageShown()
}
}
@@ -0,0 +1,179 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** The online actions of S-5 (AC-6.4, AC-6.5). */
enum class AccountAction { Archive, Unarchive, Delete }
/** A snackbar of S-5. */
sealed interface AccountMessage {
/** "Kunde inte nå servern" with "Försök igen" (§9.9). */
data class CouldNotReach(val retry: AccountAction) : AccountMessage
/** "Vardag återställdes". */
data class Restored(val name: String) : AccountMessage
/** `account_not_empty` (AC-6.5). */
data object DeleteBlocked : AccountMessage
/** The action was tried while offline (§9.9). */
data object NeedsConnection : AccountMessage
/** Any other error code of §14.3.2. */
data class Failed(val code: String) : AccountMessage
}
/** A one-off navigation of S-5, handled by the screen and then cleared. */
sealed interface AccountEvent {
/** Archived: back to Home with "Vardag arkiverades" (design-spec S-5). */
data class Archived(val name: String) : AccountEvent
/** Deleted: back to Home. */
data object Deleted : AccountEvent
/** The account no longer exists (deleted elsewhere). */
data object Gone : AccountEvent
}
/** What the account screen (S-5) shows. */
sealed interface AccountDetailUi {
data object Starting : AccountDetailUi
data class Ready(
val detail: AccountDetailState,
val online: Boolean,
val colorIndex: Int,
val menuOpen: Boolean = false,
val deleteDialog: Boolean = false,
val busy: Boolean = false,
val message: AccountMessage? = null,
val event: AccountEvent? = null,
) : AccountDetailUi {
val partner: User get() = detail.balance.partner
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: partner
/** The viewer's default share in percent, for the split bar. */
val viewerPercent: Float get() = (detail.account.defaultShares[me.id] ?: HALF_BP) / BP_PER_PERCENT
/** Archive, unarchive, edit and delete need the server (AC-6.7, §9.9). */
val canEdit: Boolean get() = online && !busy
val canDelete: Boolean get() = canEdit && detail.deletable
/** Archived accounts hide the FAB (AC-6.4, UX-6). */
val showFab: Boolean get() = !detail.account.archived
}
data class Gone(val event: AccountEvent?) : AccountDetailUi
}
private data class AccountLocal(
val menuOpen: Boolean = false,
val deleteDialog: Boolean = false,
val busy: Boolean = false,
val message: AccountMessage? = null,
val event: AccountEvent? = null,
)
internal const val HALF_BP = 5_000
internal const val BP_PER_PERCENT = 100f
/** State holder of the account screen (S-5). The account id comes from the navigation argument [ARG_ACCOUNT_ID]. */
@HiltViewModel
class AccountDetailViewModel @Inject constructor(
savedState: SavedStateHandle,
private val accounts: AccountRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
private val local = MutableStateFlow(AccountLocal())
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local) { detail, all, online, l ->
if (detail == null) {
AccountDetailUi.Gone(l.event ?: AccountEvent.Gone)
} else {
AccountDetailUi.Ready(
detail = detail,
online = online,
colorIndex = all.indexOfFirst { it.id == id }.coerceAtLeast(0),
menuOpen = l.menuOpen,
deleteDialog = l.deleteDialog,
busy = l.busy,
message = l.message,
event = l.event,
)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
fun setMenuOpen(open: Boolean) = local.update { it.copy(menuOpen = open) }
fun archive() = run(AccountAction.Archive)
fun unarchive() = run(AccountAction.Unarchive)
/** "Ta bort konto": asks first (design-spec S-5). */
fun askDelete() = local.update { it.copy(menuOpen = false, deleteDialog = true) }
fun dismissDelete() = local.update { it.copy(deleteDialog = false) }
fun confirmDelete() = run(AccountAction.Delete)
fun retry(action: AccountAction) = run(action)
fun messageShown() = local.update { it.copy(message = null) }
fun eventHandled() = local.update { it.copy(event = null) }
private fun run(action: AccountAction) {
val name = (ui.value as? AccountDetailUi.Ready)?.detail?.account?.name.orEmpty()
local.update { it.copy(menuOpen = false, deleteDialog = false, busy = true, message = null) }
viewModelScope.launch {
val result = when (action) {
AccountAction.Archive -> accounts.setArchived(id, true)
AccountAction.Unarchive -> accounts.setArchived(id, false)
AccountAction.Delete -> accounts.delete(id)
}
local.update { l ->
when (result) {
is OnlineResult.Ok -> when (action) {
AccountAction.Archive -> l.copy(busy = false, event = AccountEvent.Archived(name))
AccountAction.Unarchive -> l.copy(busy = false, message = AccountMessage.Restored(name))
AccountAction.Delete -> l.copy(busy = false, event = AccountEvent.Deleted)
}
OnlineResult.Offline -> l.copy(busy = false, message = AccountMessage.NeedsConnection)
is OnlineResult.Failed -> l.copy(busy = false, message = failureMessage(result.code, action))
}
}
}
}
companion object {
/** The navigation argument holding the account id (`:app` names its route argument so). */
const val ARG_ACCOUNT_ID = "accountId"
}
}
private fun failureMessage(code: String, action: AccountAction): AccountMessage = when (code) {
NETWORK -> AccountMessage.CouldNotReach(action)
"account_not_empty" -> AccountMessage.DeleteBlocked
else -> AccountMessage.Failed(code)
}
/** `OnlineResult.Failed` code of a network failure (design-spec §1, §9.9). */
internal const val NETWORK = "network"
@@ -0,0 +1,307 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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
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.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.ConnectedButtonGroup
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.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SplitSlider
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.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** Actions of the account editor; `:app` wires the navigation lambdas (D-017). */
data class AccountEditorActions(
val onClose: () -> Unit = {},
val onName: (String) -> Unit = {},
val onPercent: (PercentField, String) -> Unit = { _, _ -> },
val onViewerPercent: (Int) -> Unit = {},
val onSave: () -> Unit = {},
val onDiscard: () -> Unit = {},
val onKeepEditing: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/**
* Account editor (S-6): a new account without [AccountDetailViewModel.ARG_ACCOUNT_ID], else that account. [onSaved]
* gets the saved account's id (new: open S-5 of it; edit: back to S-5). [onClose] closes without saving.
*/
@Composable
fun AccountEditorScreen(
onSaved: (AccountId) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountEditorViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
LaunchedEffect(ui.saved) {
val saved = ui.saved ?: return@LaunchedEffect
viewModel.savedHandled()
onSaved(saved)
}
val actions = AccountEditorActions(
onClose = { if (viewModel.requestClose()) onClose() },
onName = viewModel::onName,
onPercent = viewModel::onPercent,
onViewerPercent = viewModel::onViewerPercent,
onSave = viewModel::save,
onDiscard = onClose,
onKeepEditing = viewModel::keepEditing,
onMessageShown = viewModel::messageShown,
)
AccountEditorContent(ui, actions, modifier)
}
/** The stateless account editor, rendered by [AccountEditorScreen], previews and screenshot tests. */
@Composable
fun AccountEditorContent(ui: AccountEditorUi, actions: AccountEditorActions, modifier: Modifier = Modifier) {
val snackbar = remember { SnackbarHostState() }
ui.message?.let { EditorMessageEffect(it, snackbar, actions) }
KvittScaffold(
modifier = modifier,
topBar = {
KvittTopAppBar(
stringResource(if (ui.editing == null) R.string.account_editor_new else R.string.account_editor_edit),
navigation = TopBarNavigation.Close,
onNavigation = actions.onClose,
actions = { SaveAction(ui, actions) },
)
},
snackbarHostState = snackbar,
) { padding ->
val me = ui.me
val partner = ui.partner
if (me == null || partner == null) {
Box(Modifier.fillMaxSize().padding(padding))
} else {
ProvideHousehold(me, partner) { EditorForm(ui, me, partner, actions, padding) }
}
if (ui.discardDialog) {
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(DesignR.string.common_discard_title),
body = "",
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
onDismiss = actions.onKeepEditing,
destructive = true,
)
}
}
}
/** "Spara" (small filled); a progress indicator in its place 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)
}
}
@Composable
private fun EditorForm(ui: AccountEditorUi, me: User, partner: User, actions: AccountEditorActions, padding: PaddingValues) {
val spacing = KvittTheme.spacing
val focus = remember { FocusRequester() }
LaunchedEffect(ui.loaded) {
if (ui.loaded && ui.editing == null && ui.online) focus.requestFocus()
}
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = spacing.gutter, vertical = spacing.s1),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
if (!ui.online) {
NeedsConnectionBanner(stringResource(if (ui.editing == null) R.string.account_editor_offline_body else R.string.account_editor_offline_body_edit))
}
KvittTextField(
value = ui.name,
onValueChange = actions.onName,
label = stringResource(DesignR.string.common_name),
modifier = Modifier.focusRequester(focus),
error = nameErrorText(ui.nameError),
capitalization = KeyboardCapitalization.Sentences,
enabled = ui.enabled,
)
SplitCard(ui, me, partner, actions)
}
}
@Composable
private fun nameErrorText(error: NameError?): String? = when (error) {
null -> null
NameError.Required -> stringResource(DesignR.string.common_name_required)
NameError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, MAX_NAME, MAX_NAME)
is NameError.Taken -> stringResource(R.string.account_editor_name_taken, error.name)
NameError.Invalid -> stringResource(DesignR.string.error_validation)
}
/** The "Standarddelning" card: people, slider, percent fields, presets 40/60 · 50/50 · 60/40 and the preview. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun SplitCard(ui: AccountEditorUi, me: User, partner: User, actions: AccountEditorActions) {
val language = kvittLanguage()
val spacing = KvittTheme.spacing
val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
val bp = ui.viewerBp
fun text(field: PercentField, value: Int?): String = when {
ui.typed == field -> ui.typedText
value == null -> ""
else -> MoneyFormatter.basisPoints(value, language)
}
val viewerText = text(PercentField.Viewer, bp)
val partnerText = text(PercentField.Partner, bp?.let { BP_TOTAL - it })
val ratioError = if (ui.splitError || (bp == null && ui.typedText.isNotEmpty())) stringResource(DesignR.string.error_ratio_invalid) else null
KvittCard(
Modifier.fillMaxWidth().alpha(if (ui.enabled) 1f else KvittDimens.DISABLED_REASON_ALPHA),
contentPadding = PaddingValues(KvittDimens.cardPaddingLarge),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
Column(verticalArrangement = Arrangement.spacedBy(spacing.s1)) {
Text(stringResource(R.string.account_editor_default_split), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.account_editor_split_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically) {
PersonAvatar(me, KvittDimens.avatarSplit)
Text(me.displayName, Modifier.weight(1f).padding(start = spacing.s2), style = MaterialTheme.typography.titleSmall)
Text(partner.displayName, Modifier.padding(end = spacing.s2), style = MaterialTheme.typography.titleSmall)
PersonAvatar(partner, KvittDimens.avatarSplit)
}
SplitSlider(me, partner, (bp ?: HALF_BP) / BP_PER_PERCENT.toInt(), actions.onViewerPercent)
val viewerField = @Composable { m: Modifier ->
PercentInput(me.displayName, viewerText, ratioError, ui.enabled, m) { actions.onPercent(PercentField.Viewer, it) }
}
val partnerField = @Composable { m: Modifier ->
PercentInput(partner.displayName, partnerText, null, ui.enabled, m) { actions.onPercent(PercentField.Partner, it) }
}
if (large) {
viewerField(Modifier.fillMaxWidth())
partnerField(Modifier.fillMaxWidth())
} else {
Row(horizontalArrangement = Arrangement.spacedBy(spacing.s3)) {
viewerField(Modifier.weight(1f))
partnerField(Modifier.weight(1f))
}
}
val presets = PRESETS
val selected = presets.indexOfFirst { bp == it * BP_PER_PERCENT.toInt() }
ConnectedButtonGroup(
options = presets.map { stringResource(DesignR.string.common_ratio, it.toString(), (100 - it).toString()) },
selected = selected,
onSelect = { actions.onViewerPercent(presets[it]) },
groupLabel = stringResource(R.string.account_editor_default_split),
modifier = Modifier.fillMaxWidth(),
spoken = presets.map { ratioSpoken(me, it * BP_PER_PERCENT.toInt(), partner) },
)
if (bp != null) Preview(bp, me, partner)
}
}
private val PRESETS = listOf(40, 50, 60)
@Composable
private fun PercentInput(name: String, text: String, error: String?, enabled: Boolean, modifier: Modifier, onChange: (String) -> Unit) {
KvittTextField(
value = text,
onValueChange = onChange,
label = name,
modifier = modifier,
error = error,
keyboardType = KeyboardType.Decimal,
enabled = enabled,
suffix = stringResource(DesignR.string.editor_percent_suffix),
)
}
/**
* "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" (`account_editor_preview`). Of 100,00 kr (10 000 öre) a share of
* `bp` basis points is exactly `bp` öre, so no rounding rule is involved.
*/
@Composable
private fun Preview(viewerBp: Int, me: User, partner: User) {
val scheme = MaterialTheme.colorScheme
val parts = listOf(me to viewerBp.toLong(), partner to (BP_TOTAL - viewerBp).toLong())
.map { (user, ore) -> stringResource(DesignR.string.common_user_split, user.displayName, money(ore)) }
.joinToString(stringResource(DesignR.string.common_separator_comma))
val text = stringResource(R.string.account_editor_preview, money(PREVIEW_ORE), parts)
FlowRow(
Modifier
.fillMaxWidth()
.background(scheme.surfaceContainerHigh, KvittTheme.shapes.field)
.padding(horizontal = KvittDimens.directionPadding, vertical = KvittTheme.spacing.s3)
.clearAndSetSemantics { contentDescription = text },
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
itemVerticalAlignment = Alignment.CenterVertically,
) {
KvittIconImage(KvittIcon.Calculate, null, tint = scheme.onSurfaceVariant)
Text(text, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
}
}
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)
if (result == SnackbarResult.ActionPerformed) actions.onSave()
actions.onMessageShown()
}
}
@@ -0,0 +1,201 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** Which percent field the user typed in last (the other one follows as 100 minus it, AC-6.1). */
enum class PercentField { Viewer, Partner }
/** Why the name field is in error (S-6). */
sealed interface NameError {
data object Required : NameError
/** More than [MAX_NAME] code points (§9.6). */
data object TooLong : NameError
/** `name_taken` (AC-6.2) for [name]. */
data class Taken(val name: String) : NameError
/** `validation_failed` on the name from the server. */
data object Invalid : NameError
}
/** A snackbar of S-6. */
sealed interface EditorMessage {
data object CouldNotReach : EditorMessage
data object NeedsConnection : EditorMessage
data class Failed(val code: String) : EditorMessage
}
/**
* What the account editor (S-6) shows.
*
* @property viewerBp the viewer's default share in bp, null while the typed percent is not a valid 0–100 with at most
* two decimals; the partner's is always `10 000 − viewerBp` (AC-6.1).
* @property typed the field typed in and its raw [typedText]; the other field shows the formatted complement.
* @property saved the account id after a successful save (new: the new account), consumed by the screen.
*/
data class AccountEditorUi(
val editing: AccountId?,
val me: User? = null,
val partner: User? = null,
val loaded: Boolean = false,
val name: String = "",
val viewerBp: Int? = HALF_BP,
val typed: PercentField? = null,
val typedText: String = "",
val online: Boolean = true,
val saving: Boolean = false,
val serverNameError: NameError? = null,
val splitError: Boolean = false,
val message: EditorMessage? = null,
val discardDialog: Boolean = false,
val saved: AccountId? = null,
val initialName: String = "",
val initialBp: Int = HALF_BP,
) {
/** The name error shown: a live length check, else the server's answer to the last save. */
val nameError: NameError? get() = if (name.trim().codePointCount() > MAX_NAME) NameError.TooLong else serverNameError
/** "Spara": a non-blank name within the limit, percentages that sum to 100, online, not already saving (S-6). */
val canSave: Boolean get() = loaded && online && !saving && name.isNotBlank() && viewerBp != null && nameError != NameError.TooLong
/** Field, card and "Spara" are disabled offline (AC-6.7). */
val enabled: Boolean get() = online && !saving
val dirty: Boolean get() = name != initialName || viewerBp != initialBp
}
private fun String.codePointCount(): Int = codePointCount(0, length)
/** §9.6: account names are 1–60 code points. */
internal const val MAX_NAME = 60
/** State holder of the account editor (S-6). [AccountDetailViewModel.ARG_ACCOUNT_ID] absent: a new account. */
@HiltViewModel
class AccountEditorViewModel @Inject constructor(
savedState: SavedStateHandle,
private val accounts: AccountRepository,
session: SessionRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val editing: AccountId? = savedState.get<String>(AccountDetailViewModel.ARG_ACCOUNT_ID)?.let(::AccountId)
private val form = MutableStateFlow(AccountEditorUi(editing))
val ui: StateFlow<AccountEditorUi> = combine(form, connectivity.online) { f, online -> f.copy(online = online) }
.stateIn(viewModelScope, SharingStarted.Eagerly, AccountEditorUi(editing))
init {
viewModelScope.launch {
val paired = session.session.filterIsInstance<SessionState.Paired>().first()
val account = editing?.let { id -> accounts.all().first().firstOrNull { it.id == id } }
val name = account?.name.orEmpty()
val bp = account?.defaultShares?.get(paired.me.id) ?: HALF_BP
form.update {
it.copy(me = paired.me, partner = paired.partner, loaded = true, name = name, viewerBp = bp, initialName = name, initialBp = bp)
}
}
}
fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) }
/** A typed percent in [field]: digits with `,` or `.` and at most two decimals (AC-6.1). */
fun onPercent(field: PercentField, text: String) {
if (!PERCENT_INPUT.matches(text)) return
val bp = parsePercentBp(text)
form.update {
it.copy(
typed = field,
typedText = text,
viewerBp = bp?.let { v -> if (field == PercentField.Viewer) v else BP_TOTAL - v },
splitError = false,
)
}
}
/** The slider and the presets set whole percents for the viewer. */
fun onViewerPercent(percent: Int) = form.update {
it.copy(viewerBp = percent.coerceIn(0, 100) * BP_PER_PERCENT.toInt(), typed = null, typedText = "", splitError = false)
}
fun save() {
val f = form.value
val me = f.me ?: return
val partner = f.partner ?: return
val bp = f.viewerBp ?: return
if (!ui.value.canSave) return
form.update { it.copy(saving = true, message = null, serverNameError = null, splitError = false) }
viewModelScope.launch {
val shares = mapOf(me.id to bp, partner.id to BP_TOTAL - bp)
val result: OnlineResult<AccountId> = if (editing == null) {
accounts.create(f.name, shares)
} else {
when (val r = accounts.update(editing, f.name, shares)) {
is OnlineResult.Ok -> OnlineResult.Ok(editing)
OnlineResult.Offline -> OnlineResult.Offline
is OnlineResult.Failed -> r
}
}
form.update { s ->
when (result) {
is OnlineResult.Ok -> s.copy(saving = false, saved = result.value)
OnlineResult.Offline -> s.copy(saving = false, message = EditorMessage.NeedsConnection)
is OnlineResult.Failed -> s.failed(result.code, result.field)
}
}
}
}
/** Close: with changes the discard dialog first (design-spec S-6); returns true when the screen may close now. */
fun requestClose(): Boolean {
if (!form.value.dirty) return true
form.update { it.copy(discardDialog = true) }
return false
}
fun keepEditing() = form.update { it.copy(discardDialog = false) }
fun messageShown() = form.update { it.copy(message = null) }
fun savedHandled() = form.update { it.copy(saved = null) }
}
private fun AccountEditorUi.failed(code: String, field: String?): AccountEditorUi = when {
code == "name_taken" -> copy(saving = false, serverNameError = NameError.Taken(name.trim()))
code == "validation_failed" && field == "name" -> copy(saving = false, serverNameError = NameError.Invalid)
code == "validation_failed" && field == "default_shares" -> copy(saving = false, splitError = true)
code == NETWORK -> copy(saving = false, message = EditorMessage.CouldNotReach)
else -> copy(saving = false, message = EditorMessage.Failed(code))
}
/** Digits with an optional `,` or `.` and at most two decimals, up to "100". */
private val PERCENT_INPUT = Regex("""^\d{0,3}([.,]\d{0,2})?$""")
/** "60,5" -> 6050; null when empty or outside 0–100 (AC-6.1: percent with up to two decimals, stored as bp). */
internal fun parsePercentBp(text: String): Int? {
val normalized = text.replace(',', '.')
if (normalized.isEmpty() || normalized == ".") return null
val whole = normalized.substringBefore('.').ifEmpty { "0" }.toIntOrNull() ?: return null
val decimals = normalized.substringAfter('.', "").padEnd(2, '0').toIntOrNull() ?: return null
val bp = whole * 100 + decimals
return bp.takeIf { it in 0..BP_TOTAL }
}
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen
* that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md).
*/
@Composable
fun AccountEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,24 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
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
},
)
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:account, from docs/ux/strings.csv (module=account). -->
<resources>
<!-- "50 / 50 · Jacob och Anna"; %1$s ratio, %2$s names with common_and -->
<string name="account_default_split">%1$s · %2$s</string>
<!-- TalkBack; %1$s editor_ratio_spoken -->
<string name="account_default_split_spoken">Standarddelning: %1$s</string>
<string name="account_settle_this">Gör upp det här kontot</string>
<string name="account_edit">Ändra konto</string>
<string name="account_archive">Arkivera</string>
<string name="account_unarchive">Återställ från arkiv</string>
<string name="account_delete">Ta bort konto</string>
<!-- AC-6.5; also the error account_not_empty -->
<string name="account_delete_blocked">Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter</string>
<string name="account_delete_confirm_title">Ta bort kontot %1$s?</string>
<string name="account_delete_confirm_body">Det går inte att ångra.</string>
<string name="account_archived_snackbar">%1$s arkiverades</string>
<string name="account_unarchived_snackbar">%1$s återställdes</string>
<string name="account_empty_title">Inga utgifter än</string>
<string name="account_empty_body">Lägg till den första utgiften i %1$s.</string>
<string name="account_editor_new">Nytt konto</string>
<string name="account_editor_edit">Ändra konto</string>
<string name="account_editor_default_split">Standarddelning</string>
<!-- "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" -->
<string name="account_editor_preview">Av %1$s: %2$s</string>
<!-- AC-6.3; S-6 under the split control (new and edit) -->
<string name="account_editor_split_hint">Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning.</string>
<!-- §9.9; under the title common_needs_connection; new account -->
<string name="account_editor_offline_body">Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot.</string>
<!-- §9.9; edit mode -->
<string name="account_editor_offline_body_edit">Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen.</string>
<!-- name_taken -->
<string name="account_editor_name_taken">Det finns redan ett konto som heter %1$s</string>
</resources>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:account, from docs/ux/strings.csv (module=account). -->
<resources>
<!-- "50 / 50 · Jacob och Anna"; %1$s ratio, %2$s names with common_and -->
<string name="account_default_split">%1$s · %2$s</string>
<!-- TalkBack; %1$s editor_ratio_spoken -->
<string name="account_default_split_spoken">Default split: %1$s</string>
<string name="account_settle_this">Settle this account</string>
<string name="account_edit">Edit account</string>
<string name="account_archive">Archive</string>
<string name="account_unarchive">Restore from archive</string>
<string name="account_delete">Delete account</string>
<!-- AC-6.5; also the error account_not_empty -->
<string name="account_delete_blocked">The account cannot be deleted while it has expenses, payments or recurring expenses</string>
<string name="account_delete_confirm_title">Delete the account %1$s?</string>
<string name="account_delete_confirm_body">This cannot be undone.</string>
<string name="account_archived_snackbar">%1$s was archived</string>
<string name="account_unarchived_snackbar">%1$s was restored</string>
<string name="account_empty_title">No expenses yet</string>
<string name="account_empty_body">Add the first expense in %1$s.</string>
<string name="account_editor_new">New account</string>
<string name="account_editor_edit">Edit account</string>
<string name="account_editor_default_split">Default split</string>
<!-- "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" -->
<string name="account_editor_preview">Of %1$s: %2$s</string>
<!-- AC-6.3; S-6 under the split control (new and edit) -->
<string name="account_editor_split_hint">Applies to new expenses in the account. Each expense can change its own split.</string>
<!-- §9.9; under the title common_needs_connection; new account -->
<string name="account_editor_offline_body">Accounts are saved straight to the server. Connect the phone to the internet to create the account.</string>
<!-- §9.9; edit mode -->
<string name="account_editor_offline_body_edit">Accounts are saved straight to the server. Connect the phone to the internet to save the change.</string>
<!-- name_taken -->
<string name="account_editor_name_taken">There is already an account called %1$s</string>
</resources>
@@ -0,0 +1,86 @@
package se.hagstedt.kvitt.feature.account
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
private const val LONG_NAME = "Gemensamma utgifter för sommarstugan i Österåker och båten i Vaxholm"
/** Every state of S-5 and S-6 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
val accountStates: List<ScreenState> = listOf(
detail("S-5-account", AccountScenarios.loaded, AccountScenarios.vardag),
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() },
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
editor("S-6-account-editor-create", AccountScenarios.editor),
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
editor("S-6-account-editor-edit", AccountScenarios.editor, id = AccountScenarios.huset),
editor("S-6-account-editor-decimals", AccountScenarios.editor) { it.onName("Sommarstugan"); it.onPercent(PercentField.Viewer, "62,75") },
editor("S-6-account-editor-error", AccountScenarios.editor) { it.onName("Vardag"); it.save() },
editor("S-6-account-editor-validation", AccountScenarios.editor) { it.onName(LONG_NAME) },
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = NETWORK) { it.onName("Sommarstugan"); it.save() },
editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
saving(),
)
private fun saving(): ScreenState {
val ui = editorState(AccountScenarios.editor) { it.onName("Sommarstugan"); it.onViewerPercent(60) }.copy(saving = true)
return ScreenState("S-6-account-editor-saving") { AccountEditorContent(ui, AccountEditorActions()) }
}
private fun detail(name: String, scenario: FakeScenario, id: AccountId, failWith: String? = null, largeFont: Boolean = false, act: (AccountDetailViewModel) -> Unit = {}): ScreenState {
val ui = detailState(scenario, id, failWith, act)
return ScreenState(name, largeFont) { AccountDetailContent(ui, AccountDetailActions()) }
}
private fun editor(name: String, scenario: FakeScenario, id: AccountId? = null, failWith: String? = null, largeFont: Boolean = false, act: (AccountEditorViewModel) -> Unit = {}): ScreenState {
val ui = editorState(scenario, id, failWith, act = act)
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
}
/**
* Goldens of every account detail and account editor state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:account:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class AccountScreenshotTest(private val state: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val screen = accountStates.first { it.name == state }
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = listOf(
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
"S-6-account-editor-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
"S-6-account-editor-saving",
).map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,43 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.UserId
/** The fake accounts with every online call failing with [code] (a REST error, §9.9); reads are the fakes'. */
class FailingAccounts(private val delegate: AccountRepository, private val code: String, private val field: String? = null) : AccountRepository by delegate {
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = OnlineResult.Failed(code, field)
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = OnlineResult.Failed(code, field)
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = OnlineResult.Failed(code, field)
override suspend fun delete(id: AccountId): OnlineResult<Unit> = OnlineResult.Failed(code, field)
}
fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) emptyMap() else mapOf(AccountDetailViewModel.ARG_ACCOUNT_ID to id.value))
/** S-5 for [id] in [scenario] after [act]; [failWith] makes the online calls fail with that code. */
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity)
act(vm)
vm.ui.value
}
/** S-6 in [scenario], editing [id] or new, after [act]. */
fun editorState(
scenario: FakeScenario,
id: AccountId? = null,
failWith: String? = null,
field: String? = null,
act: (AccountEditorViewModel) -> Unit = {},
): AccountEditorUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith, field)
val vm = AccountEditorViewModel(args(id), accounts, fakes.session, fakes.connectivity)
act(vm)
vm.ui.value
}
@@ -0,0 +1,215 @@
package se.hagstedt.kvitt.feature.account
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
class AccountDetailViewModelTest {
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
@Test
fun loadedAccountShowsBalanceMonthsAndFab() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag))
assertEquals(118_450L, ui.detail.balance.netOre)
assertEquals(listOf("2026-10", "2026-09"), ui.detail.months.map { it.period })
assertEquals(0, ui.colorIndex)
assertEquals(50f, ui.viewerPercent)
assertTrue(ui.showFab)
assertFalse(ui.canDelete)
assertTrue(ui.canEdit)
}
@Test
fun archivedAccountHidesTheFab() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland))
assertFalse(ui.showFab)
assertEquals(3, ui.colorIndex)
}
@Test
fun viewerPercentComesFromBasisPoints() {
assertEquals(60f, ready(detailState(AccountScenarios.loaded, AccountScenarios.huset)).viewerPercent)
}
@Test
fun emptyAccountMayBeDeletedOnlyOnline() {
assertTrue(ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id)).canDelete)
val offline = AccountScenarios.empty.copy(online = false)
val ui = ready(detailState(offline, AccountScenarios.sommarstugan.id))
assertFalse(ui.canDelete)
assertFalse(ui.canEdit)
}
@Test
fun archiveNavigatesBackWithTheName() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true); it.archive() })
assertEquals(AccountEvent.Archived("Vardag"), ui.event)
assertFalse(ui.menuOpen)
assertTrue(ui.detail.account.archived)
}
@Test
fun unarchiveStaysWithASnackbar() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland) { it.unarchive() })
assertEquals(AccountMessage.Restored("Gotland 2026"), ui.message)
assertFalse(ui.detail.account.archived)
assertNull(ui.event)
}
@Test
fun networkFailureOffersRetryAndChangesNothing() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() })
assertEquals(AccountMessage.CouldNotReach(AccountAction.Archive), ui.message)
assertFalse(ui.detail.account.archived)
assertFalse(ui.busy)
val shown = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive(); it.messageShown() })
assertNull(shown.message)
}
@Test
fun offlineActionSaysNeedsConnection() {
val ui = ready(detailState(AccountScenarios.offline, AccountScenarios.vardag) { it.archive() })
assertEquals(AccountMessage.NeedsConnection, ui.message)
}
@Test
fun deleteAsksThenDeletes() {
val asked = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() })
assertTrue(asked.deleteDialog)
val dismissed = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.dismissDelete() })
assertFalse(dismissed.deleteDialog)
val deleted = detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.confirmDelete() }
assertEquals(AccountDetailUi.Gone(AccountEvent.Deleted), deleted)
}
@Test
fun deleteOfANonEmptyAccountIsRefused() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.confirmDelete() })
assertEquals(AccountMessage.DeleteBlocked, ui.message)
}
@Test
fun otherErrorsKeepTheirCode() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = "forbidden") { it.archive() })
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
}
@Test
fun unknownAccountIsGone() {
assertEquals(AccountDetailUi.Gone(AccountEvent.Gone), detailState(AccountScenarios.loaded, AccountId("missing")))
}
}
class AccountEditorViewModelTest {
@Test
fun newAccountStartsAtFiftyFiftyAndNeedsAName() {
val ui = editorState(AccountScenarios.editor)
assertTrue(ui.loaded)
assertNull(ui.editing)
assertEquals(5_000, ui.viewerBp)
assertEquals(FakeScenarios.owner, ui.me)
assertFalse(ui.canSave)
assertTrue(editorState(AccountScenarios.editor) { it.onName("Sommarstugan") }.canSave)
assertFalse(editorState(AccountScenarios.editor) { it.onName(" ") }.canSave)
}
@Test
fun editStartsFromTheAccount() {
val ui = editorState(AccountScenarios.editor, id = AccountScenarios.huset)
assertEquals("Huset", ui.name)
assertEquals(6_000, ui.viewerBp)
assertFalse(ui.dirty)
}
@Test
fun percentsAcceptTwoDecimalsAndTheOtherFollows() {
assertEquals(6_275, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75") }.viewerBp)
assertEquals(10_000 - 3_350, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Partner, "33.5") }.viewerBp)
// A third decimal is not accepted: the text stays as it was.
val third = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75"); it.onPercent(PercentField.Viewer, "62,755") }
assertEquals("62,75", third.typedText)
// Over 100 is not a valid ratio: Spara is off.
val over = editorState(AccountScenarios.editor) { it.onName("X"); it.onPercent(PercentField.Viewer, "101") }
assertNull(over.viewerBp)
assertFalse(over.canSave)
assertNull(editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "") }.viewerBp)
}
@Test
fun parsePercent() {
assertEquals(6_000, parsePercentBp("60"))
assertEquals(6_050, parsePercentBp("60,5"))
assertEquals(5, parsePercentBp("0.05"))
assertEquals(10_000, parsePercentBp("100"))
assertNull(parsePercentBp("100,01"))
assertNull(parsePercentBp(","))
}
@Test
fun sliderAndPresetsSetWholePercents() {
val ui = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "12,5"); it.onViewerPercent(40) }
assertEquals(4_000, ui.viewerBp)
assertNull(ui.typed)
}
@Test
fun createSavesBasisPointsAndReportsTheNewId() {
val fakes = FakeRepositories(AccountScenarios.editor)
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
withMain { vm.onName("Sommarstugan"); vm.onPercent(PercentField.Viewer, "62,5"); vm.save() }
val saved = vm.ui.value.saved
val created = fakes.store.current.accounts.single { it.name == "Sommarstugan" }
assertEquals(created.id, saved)
assertEquals(mapOf(FakeScenarios.owner.id to 6_250, FakeScenarios.partner.id to 3_750), created.defaultShares)
}
@Test
fun nameTakenIsShownUnderTheName() {
val ui = editorState(AccountScenarios.editor) { it.onName(" vardag "); it.save() }
assertEquals(NameError.Taken("vardag"), ui.nameError)
assertFalse(ui.saving)
assertTrue(ui.canSave)
assertNull(editorState(AccountScenarios.editor) { it.onName("Vardag"); it.save(); it.onName("Vardag 2") }.nameError)
}
@Test
fun tooLongNameIsCaughtBeforeSaving() {
val ui = editorState(AccountScenarios.editor) { it.onName("a".repeat(MAX_NAME + 1)) }
assertEquals(NameError.TooLong, ui.nameError)
assertFalse(ui.canSave)
}
@Test
fun serverValidationMapsToTheField() {
assertEquals(NameError.Invalid, editorState(AccountScenarios.editor, failWith = "validation_failed", field = "name") { it.onName("X"); it.save() }.nameError)
assertTrue(editorState(AccountScenarios.editor, failWith = "validation_failed", field = "default_shares") { it.onName("X"); it.save() }.splitError)
assertEquals(EditorMessage.CouldNotReach, editorState(AccountScenarios.editor, failWith = NETWORK) { it.onName("X"); it.save() }.message)
assertEquals(EditorMessage.Failed("household_incomplete"), editorState(AccountScenarios.editor, failWith = "household_incomplete") { it.onName("X"); it.save() }.message)
}
@Test
fun offlineCannotSave() {
val ui = editorState(AccountScenarios.editorOffline) { it.onName("Sommarstugan") }
assertFalse(ui.online)
assertFalse(ui.enabled)
assertFalse(ui.canSave)
}
@Test
fun closingWithChangesAsksFirst() {
val fakes = FakeRepositories(AccountScenarios.editor)
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
assertTrue(withMain { vm.requestClose() })
withMain { vm.onName("Sommarstugan") }
assertFalse(withMain { vm.requestClose() })
assertTrue(vm.ui.value.discardDialog)
withMain { vm.keepEditing() }
assertFalse(vm.ui.value.discardDialog)
}
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.feature.account
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.captureScreenRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
* snackbars render the same every run.
*/
object ScreenSnapshots {
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private const val SETTLE_MILLIS = 1_000L
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
@OptIn(ExperimentalRoborazziApi::class)
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.mainClock.autoAdvance = false
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark, content = content)
}
}
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}