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:
@@ -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)
|
||||
}
|
||||
}
|
||||
+291
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -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 & 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>
|
||||
+100
@@ -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) }
|
||||
}
|
||||
}
|
||||
+165
@@ -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())
|
||||
}
|
||||
}
|
||||
+92
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user