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
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.home
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 HomeEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,291 @@
package se.hagstedt.kvitt.feature.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
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.ReadOnlyComposable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AccountCard
import se.hagstedt.kvitt.design.component.AddExpenseFab
import se.hagstedt.kvitt.design.component.BalanceHero
import se.hagstedt.kvitt.design.component.CollapsibleRow
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.HeroState
import se.hagstedt.kvitt.design.component.HeroWords
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittDestination
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.LocalKvittClock
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.SyncStatusPill
import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.component.kvittLanguage
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.model.AccountId
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** Navigation and actions of Home; `:app` wires the navigation lambdas (D-017). */
data class HomeActions(
val onAddExpense: () -> Unit = {},
val onSettleUp: () -> Unit = {},
val onOpenAccount: (AccountId) -> Unit = {},
val onNewAccount: () -> Unit = {},
val onOpenSyncStatus: () -> Unit = {},
val onNavigate: (KvittDestination) -> Unit = {},
val onToggleArchived: () -> Unit = {},
val onUpdate: () -> Unit = {},
val onUpdateLater: () -> Unit = {},
val onUpdateRetry: () -> Unit = {},
val onInstallContinue: () -> Unit = {},
val onInstallDismiss: () -> Unit = {},
)
/**
* Home (S-4) with the update banner and the "Tillåt installation" dialog (S-17). [snackbarHostState] lets `:app` show
* the snackbar of the screen it returns from (S-7 saved, S-9 "Betalningen sparades", S-5 archived). While
* `HomeState.partner` is null `:app` shows S-3 (`:feature:onboarding`) in this destination instead (AC-5.1).
*/
@Composable
fun HomeScreen(
onAddExpense: () -> Unit,
onSettleUp: () -> Unit,
onOpenAccount: (AccountId) -> Unit,
onNewAccount: () -> Unit,
onOpenSyncStatus: () -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: HomeViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
// The sync line's relative time is refreshed every minute (design-spec §3.6).
var minute by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(MINUTE_MILLIS)
minute += 1
}
}
val actions = HomeActions(
onAddExpense = onAddExpense,
onSettleUp = onSettleUp,
onOpenAccount = onOpenAccount,
onNewAccount = onNewAccount,
onOpenSyncStatus = onOpenSyncStatus,
onNavigate = onNavigate,
onToggleArchived = viewModel::toggleArchived,
onUpdate = viewModel::onUpdate,
onUpdateLater = viewModel::onLater,
onUpdateRetry = viewModel::onRetry,
onInstallContinue = viewModel::onInstallDialogContinue,
onInstallDismiss = viewModel::onInstallDialogDismiss,
)
HomeContent(ui, actions, modifier, snackbarHostState, minute)
}
private const val MINUTE_MILLIS = 60_000L
/** The stateless Home, rendered by [HomeScreen], previews and screenshot tests. */
@Composable
fun HomeContent(
ui: HomeUi,
actions: HomeActions,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState? = null,
tick: Int = 0,
listState: LazyListState = rememberLazyListState(),
) {
val ready = ui as? HomeUi.Ready
val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } }
val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } }
KvittScaffold(
modifier = modifier,
topBar = { KvittTopAppBar(title = null, scrolled = scrolled) },
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
floatingActionButton = { if (ready?.showFab == true) AddExpenseFab(actions.onAddExpense, expanded = fabExpanded) },
snackbarHostState = snackbarHostState,
) { padding ->
val partner = ready?.home?.partner
if (ready != null && partner != null) {
ProvideHousehold(ready.home.me, partner) {
HomeList(ready, partner, actions, padding, tick, listState)
}
} else {
Box(Modifier.fillMaxSize().padding(padding))
}
if (ready?.installDialog == true) InstallDialog(actions)
}
}
@Composable
private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padding: PaddingValues, tick: Int, listState: LazyListState) {
val spacing = KvittTheme.spacing
val words = HeroWords(
settleUp = stringResource(R.string.home_settle_up),
loadingTitle = stringResource(R.string.home_loading),
loadingBody = stringResource(R.string.home_loading_body),
)
val colorIndex = ui.colorIndex
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 = "update") { UpdateBannerItem(ui, actions) }
if (ui.loading) {
item(key = "hero") { BalanceHero(HeroState.Loading, ui.home.me, words, actions.onSettleUp) }
item(key = "pill") {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Skeleton(spacing.s1, KvittDimens.pillMinHeight, Modifier.fillMaxWidth(SKELETON_PILL_FRACTION), pill = true)
}
}
item(key = "header") { SectionHeader(stringResource(R.string.home_accounts)) }
items(SKELETON_CARDS) { Skeleton(spacing.s1, KvittDimens.accountCardMin, Modifier.fillMaxWidth()) }
return@LazyColumn
}
if (ui.empty) {
item(key = "welcome") { WelcomeCard(ui.home.me, partner) }
item(key = "empty") {
EmptyState(
title = stringResource(R.string.home_empty_title),
body = stringResource(R.string.home_empty_body),
illustration = ui.illustration,
action = stringResource(R.string.home_new_account),
actionIcon = KvittIcon.Add,
onAction = actions.onNewAccount,
)
}
return@LazyColumn
}
item(key = "hero") { BalanceHero(HeroState.of(ui.home.global), ui.home.me, words, actions.onSettleUp) }
item(key = "pill") {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
key(tick) { SyncStatusPill(ui.home.sync, ui.online, actions.onOpenSyncStatus) }
}
}
item(key = "header") {
SectionHeader(
stringResource(R.string.home_accounts),
action = stringResource(R.string.home_new_account),
actionIcon = KvittIcon.Add,
actionEnabled = ui.online,
actionSupporting = if (ui.online) null else stringResource(DesignR.string.common_needs_connection),
onAction = actions.onNewAccount,
)
}
ui.home.active.forEach { summary ->
item(key = summary.account.id.value) { HomeAccountCard(summary, partner, colorIndex[summary.account.id.value] ?: 0, actions) }
}
if (ui.home.archived.isNotEmpty()) {
item(key = "archived") {
CollapsibleRow(
title = stringResource(R.string.home_archived_section, ui.home.archived.size),
expanded = ui.archivedExpanded,
expandedState = stringResource(R.string.home_archived_expanded),
collapsedState = stringResource(R.string.home_archived_collapsed),
onToggle = actions.onToggleArchived,
)
}
if (ui.archivedExpanded) {
ui.home.archived.forEach { summary ->
item(key = summary.account.id.value) { HomeAccountCard(summary, partner, colorIndex[summary.account.id.value] ?: 0, actions) }
}
}
}
}
}
private const val SKELETON_CARDS = 3
private const val SKELETON_PILL_FRACTION = 0.5f
@Composable
private fun UpdateBannerItem(ui: HomeUi.Ready, actions: HomeActions) {
UpdateBanner(ui.update, ui.online, actions.onUpdate, actions.onUpdateLater, actions.onUpdateRetry)
}
@Composable
private fun HomeAccountCard(summary: AccountSummary, partner: User, colorIndex: Int, actions: HomeActions) {
AccountCard(summary, partner, colorIndex, expensesLine(summary.expensesThisMonth), onClick = { actions.onOpenAccount(summary.account.id) })
}
/** "11 utgifter i oktober" / "Inga utgifter i oktober" for the current month (design-spec §3.4). */
@Composable
@ReadOnlyComposable
private fun expensesLine(count: Int): String {
val today = LocalKvittClock.current.today
val period = today.year.toString() + "-" + today.month.ordinal.plus(1).toString().padStart(2, '0')
// monthYear is "oktober 2026" / "October 2026"; running text needs the month alone.
val month = DateFormatter.monthYear(period, kvittLanguage()).substringBeforeLast(' ')
return if (count > 0) pluralStringResource(R.plurals.home_account_expenses_month, count, count, month) else stringResource(R.string.home_account_no_expenses_month, month)
}
/** S-4 empty: both avatars, "Du och Anna är med", "Hushållet är klart. Nu behövs ett konto." */
@Composable
private fun WelcomeCard(me: User, partner: User) {
KvittCard(Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) {
Box {
PersonAvatar(me, KvittDimens.accountTile)
PersonAvatar(partner, KvittDimens.accountTile, Modifier.padding(start = KvittDimens.accountTile - KvittTheme.spacing.s2))
}
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(stringResource(R.string.home_empty_welcome, partner.displayName), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.home_empty_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/** S-17: "Tillåt installation", shown once before the first install (AC-16.3). */
@Composable
private fun InstallDialog(actions: HomeActions) {
ConfirmDialog(
icon = KvittIcon.InstallMobile,
title = stringResource(DesignR.string.update_permission_title),
body = stringResource(DesignR.string.update_permission_body),
confirm = stringResource(DesignR.string.common_continue),
dismiss = stringResource(DesignR.string.update_later),
onConfirm = actions.onInstallContinue,
onDismiss = actions.onInstallDismiss,
tertiaryTile = true,
confirmIcon = KvittIcon.ArrowForward,
)
}
@@ -0,0 +1,129 @@
package se.hagstedt.kvitt.feature.home
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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.HomeState
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import javax.inject.Inject
/** What Home (S-4, with the S-17 banner and dialog) shows. */
sealed interface HomeUi {
/** Before the first emission of the local copy. */
data object Starting : HomeUi
/**
* @property loading the first sync after pairing is still running (no successful sync yet): hero `Loading` and
* skeletons, no FAB.
* @property update the update banner's state, [UpdateState.None] while hidden ("Senare" until the next cold start).
* @property installDialog the "Tillåt installation" dialog is open (S-17).
* @property illustration the three tiles of the empty state (groceries, travel, restaurants; design-spec S-4).
*/
data class Ready(
val home: HomeState,
val online: Boolean,
val update: UpdateState,
val archivedExpanded: Boolean,
val installDialog: Boolean,
val loading: Boolean,
val illustration: List<Category> = emptyList(),
) : HomeUi {
/** No account at all: the empty state, no sync pill, no FAB (AC-8.6 needs an account). */
val empty: Boolean get() = !loading && home.active.isEmpty() && home.archived.isEmpty()
/** The FAB only while an active account exists (UX-6). */
val showFab: Boolean get() = !loading && home.active.isNotEmpty()
/** "Gör upp" and the hero amount only with a partner and a non-zero balance (AC-10.1). */
val canSettle: Boolean get() = (home.global?.netOre ?: 0L) != 0L
/**
* Colour slot per account: its position among all accounts by `created_at`, archived included (design-spec §2.4,
* AC-6.6).
*/
val colorIndex: Map<String, Int>
get() = (home.active + home.archived).map(AccountSummary::account)
.sortedWith(compareBy({ it.createdAt }, { it.id.value }))
.mapIndexed { index, account -> account.id.value to index }
.toMap()
}
}
/** Session-scoped choices on Home: the archived section, a hidden update banner, the install dialog. */
private data class HomeLocal(val archivedExpanded: Boolean = false, val updateHidden: Boolean = false, val installDialog: Boolean = false)
/** State holder of Home (S-4) and its update banner and dialog (S-17). */
@HiltViewModel
class HomeViewModel @Inject constructor(
home: HomeRepository,
connectivity: ConnectivityRepository,
private val updates: UpdateRepository,
categories: CategoryRepository,
) : ViewModel() {
private val local = MutableStateFlow(HomeLocal())
private val illustration = categories.all().map { all ->
EMPTY_ILLUSTRATION.mapNotNull { key -> all.firstOrNull { it.defaultKey == key } }
}
val ui: StateFlow<HomeUi> = combine(home.home(), connectivity.online, updates.state, local, illustration) { state, online, update, l, tiles ->
HomeUi.Ready(
home = state,
online = online,
update = if (l.updateHidden) UpdateState.None else update,
archivedExpanded = l.archivedExpanded,
installDialog = l.installDialog && update is UpdateState.NeedsInstallPermission,
loading = state.sync.lastSuccessAt == null,
illustration = tiles,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUi.Starting)
fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) }
/** "Uppdatera": the first time the explanation dialog (AC-16.3), otherwise the download and install. */
fun onUpdate() {
val current = (ui.value as? HomeUi.Ready)?.update
if (current is UpdateState.NeedsInstallPermission) {
local.update { it.copy(installDialog = true) }
} else {
install()
}
}
/** "Försök igen" after a failed download (AC-16.4). */
fun onRetry() = install()
/** "Senare": hides the banner until the next cold start (design-spec S-17). */
fun onLater() = local.update { it.copy(updateHidden = true, installDialog = false) }
/** "Fortsätt" in the dialog: the next `install()` proceeds to the system prompt (AC-16.3). */
fun onInstallDialogContinue() {
local.update { it.copy(installDialog = false) }
install()
}
fun onInstallDialogDismiss() = local.update { it.copy(installDialog = false) }
private fun install() {
viewModelScope.launch { updates.install() }
}
private companion object {
val EMPTY_ILLUSTRATION = listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS)
}
}
@@ -0,0 +1,291 @@
package se.hagstedt.kvitt.feature.home
import android.content.ActivityNotFoundException
import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.datetime.toLocalDateTime
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.ExpenseRow
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.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SettlementRow
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.relativeTime
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.model.EntityType
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SyncRejectionReason
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
/** Actions of the sync status screen; `:app` wires the navigation (D-017). */
data class SyncStatusActions(
val onBack: () -> Unit = {},
val onOpenExpense: (ExpenseId) -> Unit = {},
val onOpenSettlement: (SettlementId) -> Unit = {},
val onSyncNow: () -> Unit = {},
val onDismiss: (String) -> Unit = {},
val onOpenDateSettings: () -> Unit = {},
)
/** Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). */
@Composable
fun SyncStatusScreen(
onBack: () -> Unit,
onOpenExpense: (ExpenseId) -> Unit,
onOpenSettlement: (SettlementId) -> Unit,
modifier: Modifier = Modifier,
viewModel: SyncStatusViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val actions = SyncStatusActions(
onBack = onBack,
onOpenExpense = onOpenExpense,
onOpenSettlement = onOpenSettlement,
onSyncNow = viewModel::syncNow,
onDismiss = viewModel::dismiss,
onOpenDateSettings = {
try {
context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (e: ActivityNotFoundException) {
Log.w(LOG_TAG, "no date settings activity", e)
}
},
)
SyncStatusContent(ui, actions, modifier)
}
private const val LOG_TAG = "KvittSyncStatus"
/** The stateless sync status screen, rendered by [SyncStatusScreen], previews and screenshot tests. */
@Composable
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier) {
KvittScaffold(
modifier = modifier,
topBar = { KvittTopAppBar(stringResource(R.string.home_sync_status_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
) { padding ->
val ready = ui as? SyncStatusUi.Ready
if (ready == null) {
Box(Modifier.fillMaxSize().padding(padding))
return@KvittScaffold
}
ProvideHousehold(ready.me, ready.partner) {
SyncStatusList(ready, actions, padding)
}
}
}
@Composable
private fun SyncStatusList(ui: SyncStatusUi.Ready, actions: SyncStatusActions, padding: PaddingValues) {
val spacing = KvittTheme.spacing
val summary = ui.status.summary
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.s8),
verticalArrangement = Arrangement.spacedBy(spacing.detailGap),
) {
if (summary.clockSkew) item(key = "clock") { ClockBanner(actions.onOpenDateSettings) }
item(key = "status") { StatusCard(ui) }
item(key = "sync-now") {
Column {
KvittButton(
stringResource(R.string.home_sync_status_sync_now),
actions.onSyncNow,
Modifier.fillMaxWidth(),
size = ButtonSize.Medium,
icon = KvittIcon.Sync,
enabled = ui.canSyncNow,
)
if (!ui.online) NeedsConnectionHint()
}
}
if (ui.status.pending.isNotEmpty()) {
item(key = "pending-title") { SectionHeader(stringResource(R.string.home_sync_status_pending_title, ui.status.pending.size)) }
item(key = "pending") { PendingGroup(ui.status.pending, actions) }
}
if (ui.status.rejections.isNotEmpty()) {
item(key = "rejected-title") { SectionHeader(stringResource(R.string.home_sync_status_rejected_title)) }
ui.status.rejections.forEach { rejection ->
item(key = rejection.mutationId) { RejectionCard(rejection, ui, actions) }
}
}
}
}
/** Error banner of §9.10: "Telefonens klocka går fel…" with "Datum och tid". */
@Composable
private fun ClockBanner(onOpenDateSettings: () -> Unit) {
Banner(
BannerKind.Error,
KvittIcon.Schedule,
stringResource(R.string.home_sync_status_clock_warning),
body = stringResource(R.string.home_sync_status_clock_body),
) { content ->
KvittButton(stringResource(R.string.home_sync_status_open_date_settings), onOpenDateSettings, kind = ButtonKind.Text, icon = KvittIcon.OpenInNew, contentColor = content)
}
}
/** The status card: Sunny tile, the state in words over "Senast lyckade synk 13:59"; one TalkBack node. */
@Composable
private fun StatusCard(ui: SyncStatusUi.Ready) {
val scheme = MaterialTheme.colorScheme
val colors = KvittTheme.colors
val summary = ui.status.summary
val lastSuccess = summary.lastSuccessAt
val problem = summary.clockSkew || ui.failed
val title = when {
ui.failed -> stringResource(DesignR.string.sync_failed_line)
summary.running || lastSuccess == null -> stringResource(DesignR.string.sync_syncing)
!ui.online -> stringResource(DesignR.string.common_offline)
ui.allSynced -> stringResource(R.string.home_sync_status_all_synced)
else -> stringResource(DesignR.string.sync_synced_relative, relativeTime(lastSuccess))
}
val subtitle = when {
summary.clockSkew -> stringResource(R.string.home_sync_status_last_attempt_clock)
lastSuccess != null -> stringResource(R.string.home_sync_status_last_success, lastSyncTime(lastSuccess))
else -> null
}
val spoken = listOfNotNull(title, subtitle).joinToString(stringResource(DesignR.string.common_separator_comma))
KvittCard(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, contentPadding = PaddingValues(KvittDimens.cardPaddingLarge)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap)) {
Box(
Modifier.size(KvittDimens.tileEditor).background(if (problem) scheme.surfaceContainerHighest else colors.owedContainer, KvittTheme.shapes.avatarLarge),
contentAlignment = Alignment.Center,
) {
KvittIconImage(
if (problem) KvittIcon.SyncProblem else KvittIcon.CloudDone,
null,
filled = true,
tint = if (problem) scheme.onSurfaceVariant else colors.owed,
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(title, style = MaterialTheme.typography.titleMedium)
if (subtitle != null) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant)
}
}
}
}
/** "13:59" today, otherwise "16 okt. 14:00" (§9.8). */
@Composable
@ReadOnlyComposable
private fun lastSyncTime(rfc3339: String): String {
val clock = LocalKvittClock.current
val instant = Instant.parse(rfc3339)
return if (instant.toLocalDateTime(clock.zone).date == clock.today) {
DateFormatter.time(instant, clock.zone)
} else {
DateFormatter.listDateTime(instant, clock.zone, clock.today, kvittLanguage())
}
}
/** Pending rows: the ledger rows of `:core:design` (D-019), each with its pending marker. */
@Composable
private fun PendingGroup(rows: List<LedgerRow>, actions: SyncStatusActions) {
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
SegmentedGroup(rows.size) { item ->
when (val row = rows[item.index]) {
is LedgerRow.ExpenseRow -> ExpenseRow(item, row, onClick = { actions.onOpenExpense(row.id) })
is LedgerRow.SettlementRow -> SettlementRow(item, row, typeLabel, onClick = { actions.onOpenSettlement(row.id) })
}
}
}
/** One change that was not synced (§14.4.6): what, why and when, "Öppna" and "Ta bort från listan". */
@Composable
private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
val scheme = MaterialTheme.colorScheme
val clock = LocalKvittClock.current
val partnerName = ui.partner?.displayName.orEmpty()
val settlement = rejection.entityType == EntityType.SETTLEMENT
val reason = when (rejection.reason) {
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, partnerName)
SyncRejectionReason.DELETED -> stringResource(R.string.home_sync_reject_deleted)
SyncRejectionReason.VALIDATION_FAILED -> stringResource(R.string.home_sync_reject_validation)
SyncRejectionReason.REFERENCE_NOT_FOUND -> stringResource(R.string.home_sync_reject_reference)
SyncRejectionReason.CLOCK_SKEW -> stringResource(R.string.home_sync_reject_clock_skew)
}
val help = when {
rejection.reason != SyncRejectionReason.STALE -> null
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, partnerName)
else -> stringResource(R.string.home_sync_reject_stale_help, partnerName)
}
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
val open = {
if (settlement) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
}
Column(
Modifier
.fillMaxWidth()
.border(BorderStroke(KvittDimens.divider, scheme.outlineVariant), KvittTheme.shapes.accountCard)
.padding(KvittTheme.spacing.s4),
verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
) {
Row(
Modifier.semantics(mergeDescendants = true) {},
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
Box(Modifier.size(KvittDimens.tileSmall).background(scheme.surfaceContainerHighest, KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) {
KvittIconImage(if (settlement) KvittIcon.Handshake else KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.iconChip, tint = scheme.onSurfaceVariant)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(rejection.summary, style = MaterialTheme.typography.titleMedium)
Text(reasonLine, style = MaterialTheme.typography.bodyMedium)
if (help != null) Text(help, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant)
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
KvittButton(stringResource(R.string.home_sync_status_open), open, kind = ButtonKind.Text)
KvittButton(stringResource(R.string.home_sync_status_dismiss), { actions.onDismiss(rejection.mutationId) }, kind = ButtonKind.Tonal)
}
}
}
@@ -0,0 +1,57 @@
package se.hagstedt.kvitt.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.SyncStatusState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** What the sync status screen (S-16) shows. */
sealed interface SyncStatusUi {
data object Starting : SyncStatusUi
/**
* @property me the viewer and [partner] the other person: `stale` is explained with the partner's name (§14.4.6;
* `Rejection` carries no actor, see the M7b report).
* @property failed the last attempt failed: `SyncSummary` has no such field yet, so the ViewModel passes false.
*/
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val failed: Boolean = false) : SyncStatusUi {
/** Nothing pending, nothing refused, no clock problem: "Allt är synkat". */
val allSynced: Boolean
get() = status.pending.isEmpty() && status.rejections.isEmpty() && !status.summary.clockSkew && !status.summary.running && online && !failed
/** "Synka nu" is offered when online and no sync runs (§9.9 does not list it; it needs the server). */
val canSyncNow: Boolean get() = online && !status.summary.running
}
}
/** State holder of the sync status screen (S-16, AC-15.5). */
@HiltViewModel
class SyncStatusViewModel @Inject constructor(
private val sync: SyncRepository,
connectivity: ConnectivityRepository,
session: SessionRepository,
) : ViewModel() {
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session) { status, online, s ->
if (s is SessionState.Paired) SyncStatusUi.Ready(status, online, s.me, s.partner) else SyncStatusUi.Starting
}.stateIn(viewModelScope, SharingStarted.Eagerly, SyncStatusUi.Starting)
fun syncNow() {
viewModelScope.launch { sync.syncNow() }
}
/** "Ta bort från listan": works offline (design-spec S-16). */
fun dismiss(mutationId: String) {
viewModelScope.launch { sync.dismiss(mutationId) }
}
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:home, from docs/ux/strings.csv (module=home). -->
<resources>
<!-- shown on Home before the system permission prompt, after the first full sync (AC-2.9, ia.md §3) -->
<string name="home_notification_rationale">Kvitt säger till när %1$s lägger till eller ändrar något.</string>
<string name="home_accounts">Konton</string>
<string name="home_new_account">Nytt konto</string>
<string name="home_archived_section">Arkiverade (%d)</string>
<string name="home_settle_up">Gör upp</string>
<!-- plural one; %2$s month name (lowercase in sv) -->
<plurals name="home_account_expenses_month">
<item quantity="one">%1$d utgift i %2$s</item>
<item quantity="other">%1$d utgifter i %2$s</item>
</plurals>
<string name="home_account_no_expenses_month">Inga utgifter i %1$s</string>
<!-- first sync only -->
<string name="home_loading">Hämtar era utgifter…</string>
<!-- first sync only -->
<string name="home_loading_body">Första synken hämtar allt som finns på servern. Det tar bara en stund.</string>
<!-- %1$s partner name; S-4 empty state card -->
<string name="home_empty_welcome">Du och %1$s är med</string>
<string name="home_empty_subtitle">Hushållet är klart. Nu behövs ett konto.</string>
<string name="home_empty_title">Skapa ert första konto</string>
<string name="home_empty_body">Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.</string>
<string name="home_sync_status_sync_now">Synka nu</string>
<string name="home_sync_status_title">Synkstatus</string>
<string name="home_sync_status_all_synced">Allt är synkat</string>
<!-- followed by the sync line -->
<string name="home_sync_status_all_synced_body">Alla ändringar finns på servern.</string>
<!-- %1$s time or list date and time -->
<string name="home_sync_status_last_success">Senast lyckade synk %1$s</string>
<string name="home_sync_status_pending_title">Väntande ändringar (%d)</string>
<!-- pending row: home_sync_status_row_pending(tag) -->
<string name="home_sync_status_tag_new">Ny</string>
<string name="home_sync_status_tag_changed">Ändrad</string>
<string name="home_sync_status_tag_deleted">Borttagen</string>
<!-- %1$s a home_sync_status_tag_* -->
<string name="home_sync_status_row_pending">%1$s · väntar på synk</string>
<string name="home_sync_status_rejected_title">Ändringar som inte synkades</string>
<string name="home_sync_status_dismiss">Ta bort från listan</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Öppna</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Din ändring ersattes av en nyare ändring från %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
<string name="home_sync_reject_stale_help">%1$ss version gäller. Öppna utgiften om du vill göra din ändring igen.</string>
<!-- as home_sync_reject_stale_help -->
<string name="home_sync_reject_stale_help_payment">%1$ss version gäller. Öppna betalningen om du vill göra din ändring igen.</string>
<string name="home_sync_reject_deleted">Den här togs bort innan din ändring kom fram</string>
<string name="home_sync_reject_validation">Ändringen var inte giltig och sparades inte</string>
<string name="home_sync_reject_reference">Dess konto eller kategori finns inte längre</string>
<string name="home_sync_reject_clock_skew">Telefonens klocka går fel. Rätta den och synka igen</string>
<!-- §9.10 -->
<string name="home_sync_status_clock_warning">Telefonens klocka går fel. Rätta den och synka igen.</string>
<!-- §14.4.6 clock_skew -->
<string name="home_sync_status_clock_body">Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess.</string>
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Datum och tid</string>
<string name="home_sync_status_last_attempt_clock">Senaste försöket nekades: klockan</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">utfälld</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">hopfälld</string>
</resources>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:home, from docs/ux/strings.csv (module=home). -->
<resources>
<!-- shown on Home before the system permission prompt, after the first full sync (AC-2.9, ia.md §3) -->
<string name="home_notification_rationale">Kvitt tells you when %1$s adds or changes something.</string>
<string name="home_accounts">Accounts</string>
<string name="home_new_account">New account</string>
<string name="home_archived_section">Archived (%d)</string>
<string name="home_settle_up">Settle up</string>
<!-- plural one; %2$s month name (lowercase in sv) -->
<plurals name="home_account_expenses_month">
<item quantity="one">%1$d expense in %2$s</item>
<item quantity="other">%1$d expenses in %2$s</item>
</plurals>
<string name="home_account_no_expenses_month">No expenses in %1$s</string>
<!-- first sync only -->
<string name="home_loading">Getting your expenses…</string>
<!-- first sync only -->
<string name="home_loading_body">The first sync fetches everything on the server. It only takes a moment.</string>
<!-- %1$s partner name; S-4 empty state card -->
<string name="home_empty_welcome">You and %1$s are in</string>
<string name="home_empty_subtitle">The household is ready. Now you need an account.</string>
<string name="home_empty_title">Create your first account</string>
<string name="home_empty_body">An account collects expenses that you share the same way, for example Everyday, The house or a trip. You can have as many as you like.</string>
<string name="home_sync_status_sync_now">Sync now</string>
<string name="home_sync_status_title">Sync status</string>
<string name="home_sync_status_all_synced">Everything is synced</string>
<!-- followed by the sync line -->
<string name="home_sync_status_all_synced_body">All changes are on the server.</string>
<!-- %1$s time or list date and time -->
<string name="home_sync_status_last_success">Last successful sync %1$s</string>
<string name="home_sync_status_pending_title">Pending changes (%d)</string>
<!-- pending row: home_sync_status_row_pending(tag) -->
<string name="home_sync_status_tag_new">New</string>
<string name="home_sync_status_tag_changed">Changed</string>
<string name="home_sync_status_tag_deleted">Deleted</string>
<!-- %1$s a home_sync_status_tag_* -->
<string name="home_sync_status_row_pending">%1$s · waiting to sync</string>
<string name="home_sync_status_rejected_title">Changes that were not synced</string>
<string name="home_sync_status_dismiss">Remove from list</string>
<!-- opens the rejected change read-only -->
<string name="home_sync_status_open">Open</string>
<!-- §14.4.6 -->
<string name="home_sync_reject_stale">Your change was replaced by a newer change from %1$s</string>
<!-- sv genitive as onboarding_profile_joining -->
<string name="home_sync_reject_stale_help">%1$s\'s version applies. Open the expense if you want to make your change again.</string>
<!-- as home_sync_reject_stale_help -->
<string name="home_sync_reject_stale_help_payment">%1$s\'s version applies. Open the payment if you want to make your change again.</string>
<string name="home_sync_reject_deleted">This was deleted before your change arrived</string>
<string name="home_sync_reject_validation">This change was not valid and was not saved</string>
<string name="home_sync_reject_reference">Its account or category no longer exists</string>
<string name="home_sync_reject_clock_skew">Your phone\'s clock is wrong. Fix it and sync again</string>
<!-- §9.10 -->
<string name="home_sync_status_clock_warning">Your phone\'s clock is wrong. Fix it and sync again.</string>
<!-- §14.4.6 clock_skew -->
<string name="home_sync_status_clock_body">The clock is more than 10 minutes ahead of the server. Your changes stay on the phone until it is fixed.</string>
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Date &amp; time</string>
<string name="home_sync_status_last_attempt_clock">Last attempt refused: the clock</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">expanded</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">collapsed</string>
</resources>
@@ -0,0 +1,100 @@
package se.hagstedt.kvitt.feature.home
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.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.home.HomeScenarios
/** 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)
/** Home with [scenario] after [act] on its ViewModel. */
fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi = withMain {
val fakes = FakeRepositories(scenario)
val vm = HomeViewModel(fakes.home, fakes.connectivity, fakes.update, fakes.categories)
act(vm)
vm.ui.value
}
/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */
fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
val homeStates: List<ScreenState> = listOf(
home("S-4-home", HomeScenarios.household),
home("S-4-home-owes-you", HomeScenarios.owesYou),
home("S-4-home-settled", HomeScenarios.settled) { it.toggleArchived() },
home("S-4-home-pending", HomeScenarios.pending),
home("S-4-home-rejected", HomeScenarios.rejected),
home("S-4-home-clock", HomeScenarios.clockSkew),
home("S-4-home-syncing", HomeScenarios.syncing),
home("S-4-home-empty", HomeScenarios.empty),
home("S-4-home-loading", HomeScenarios.loading),
home("S-4-home-offline", HomeScenarios.offline),
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
home("S-4-home-long", HomeScenarios.long, largeFont = true),
home("S-17-update-banner", HomeScenarios.updateAvailable),
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
home("S-17-update-progress", HomeScenarios.updateDownloading),
home("S-17-update-failed", HomeScenarios.updateFailed),
sync("S-16-sync", HomeScenarios.syncLoaded),
sync("S-16-sync-empty", HomeScenarios.household),
sync("S-16-sync-rejections", HomeScenarios.allRejections),
sync("S-16-sync-clock", HomeScenarios.clockSkew),
sync("S-16-sync-running", HomeScenarios.syncing),
sync("S-16-sync-error", HomeScenarios.pending, failed = true),
sync("S-16-sync-offline", HomeScenarios.syncOffline),
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
)
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
val ui = homeState(scenario, act)
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, failed: Boolean = false): ScreenState {
val ui = syncState(scenario, failed)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
/**
* Goldens of every Home, sync status and update state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:home:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class HomeScreenshotTest(private val state: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val screen = homeStates.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-4-home", "S-4-home-owes-you", "S-4-home-settled", "S-4-home-pending", "S-4-home-rejected", "S-4-home-clock",
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
"S-16-sync-offline", "S-16-sync-long",
).map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,165 @@
package se.hagstedt.kvitt.feature.home
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
private class RecordingUpdates(initial: UpdateState) : UpdateRepository {
val flow = MutableStateFlow(initial)
var installs = 0
override val state: Flow<UpdateState> = flow
override suspend fun check() = Unit
override suspend fun install() {
installs += 1
}
}
class HomeViewModelTest {
private fun ready(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}) = homeState(scenario, act) as HomeUi.Ready
private fun vm(scenario: FakeScenario, updates: UpdateRepository): HomeViewModel = withMain {
val fakes = FakeRepositories(scenario)
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories)
}
@Test
fun householdShowsBalanceAccountsAndFab() {
val ui = ready(HomeScenarios.household)
assertEquals(-325_000L, ui.home.global?.netOre)
assertEquals(listOf("Vardag", "Huset", "Japan 2027"), ui.home.active.map { it.account.name })
assertEquals(listOf("Gotland 2026"), ui.home.archived.map { it.account.name })
assertTrue(ui.showFab)
assertTrue(ui.canSettle)
assertFalse(ui.empty)
assertFalse(ui.loading)
assertFalse(ui.archivedExpanded)
}
@Test
fun colourSlotsFollowCreatedOrderWithArchivedIncluded() {
val ui = ready(HomeScenarios.household)
val index = ui.colorIndex
assertEquals(listOf(0, 1, 2, 3), listOf(HomeScenarios.vardag, HomeScenarios.huset, HomeScenarios.japan, HomeScenarios.gotland).map { index[it.id.value] })
}
@Test
fun settledHasNoSettleButton() {
val ui = ready(HomeScenarios.settled)
assertEquals(0L, ui.home.global?.netOre)
assertFalse(ui.canSettle)
}
@Test
fun archivedSectionToggles() {
assertTrue(ready(HomeScenarios.household) { it.toggleArchived() }.archivedExpanded)
assertFalse(ready(HomeScenarios.household) { it.toggleArchived(); it.toggleArchived() }.archivedExpanded)
}
@Test
fun emptyAndOnlyArchivedHideTheFab() {
val empty = ready(HomeScenarios.empty)
assertTrue(empty.empty)
assertFalse(empty.showFab)
assertEquals(listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS), empty.illustration.map { it.defaultKey })
val archivedOnly = ready(HomeScenarios.onlyArchived)
assertFalse(archivedOnly.empty)
assertFalse(archivedOnly.showFab)
}
@Test
fun firstSyncIsLoading() {
val ui = ready(HomeScenarios.loading)
assertTrue(ui.loading)
assertFalse(ui.empty)
assertFalse(ui.showFab)
}
@Test
fun offlineAndPendingAreReported() {
val ui = ready(HomeScenarios.offline)
assertFalse(ui.online)
assertEquals(2, ui.home.sync.pendingCount)
assertTrue(ui.update is UpdateState.Available)
}
@Test
fun laterHidesTheBanner() {
assertEquals(UpdateState.None, ready(HomeScenarios.updateAvailable) { it.onLater() }.update)
}
@Test
fun firstUpdateOpensTheDialogAndContinueInstalls() {
val updates = RecordingUpdates(UpdateState.NeedsInstallPermission(HomeScenarios.update))
val vm = vm(HomeScenarios.household, updates)
withMain { vm.onUpdate() }
assertTrue((vm.ui.value as HomeUi.Ready).installDialog)
assertEquals(0, updates.installs)
withMain { vm.onInstallDialogContinue() }
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
assertEquals(1, updates.installs)
}
@Test
fun updateAndRetryInstallDirectly() {
val updates = RecordingUpdates(UpdateState.Available(HomeScenarios.update))
val vm = vm(HomeScenarios.household, updates)
withMain { vm.onUpdate() }
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
withMain { vm.onRetry() }
assertEquals(2, updates.installs)
}
@Test
fun dialogDismissKeepsTheBanner() {
val ui = ready(HomeScenarios.updateNeedsPermission) { it.onUpdate(); it.onInstallDialogDismiss() }
assertFalse(ui.installDialog)
assertTrue(ui.update is UpdateState.NeedsInstallPermission)
}
}
class SyncStatusViewModelTest {
@Test
fun syncedHouseholdIsAllSynced() {
val ui = syncState(HomeScenarios.household) as SyncStatusUi.Ready
assertTrue(ui.allSynced)
assertTrue(ui.canSyncNow)
}
@Test
fun pendingAndRejectionsAreListed() {
val ui = syncState(HomeScenarios.allRejections) as SyncStatusUi.Ready
assertFalse(ui.allSynced)
assertEquals(2, ui.status.pending.size)
assertEquals(SyncRejectionReason.entries.toSet(), ui.status.rejections.map { it.reason }.toSet())
assertEquals("Anna", ui.partner?.displayName)
}
@Test
fun syncNowIsDisabledOfflineAndWhileRunning() {
assertFalse((syncState(HomeScenarios.syncOffline) as SyncStatusUi.Ready).canSyncNow)
assertFalse((syncState(HomeScenarios.syncing) as SyncStatusUi.Ready).canSyncNow)
}
@Test
fun syncNowClearsPendingAndDismissRemovesARejection() {
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
withMain { vm.syncNow() }
val synced = vm.ui.value as SyncStatusUi.Ready
assertTrue(synced.status.pending.isEmpty())
assertEquals(1, synced.status.rejections.size)
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
}
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.feature.home
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()
}
}