Android: pull-to-refresh on Home and account detail (D-127)

KvittPullToRefresh in :core:design wraps Material 3's PullToRefreshBox with
Kvitt's own indicator: a primary-container disc (the hero's first-sync tile
look) with the sync icon, which winds with the pull while a primary arc fills
to the threshold, pops at the threshold with a GestureThresholdActivate haptic,
and turns into an indeterminate ring while the sync runs. Material 3 1.4.0 has
no LoadingIndicator or LinearWavyProgressIndicator, so the circular indicator
stands in; the swap happens in Refresh.kt only.

Home (S-4) and account detail (S-5) call syncNow() of SyncRepository, the path
of S-16's "Synka nu"; the indicator stays until it returns, success or failure.
Offline the pull ends at once without a sync. TalkBack reads "Synkar…"
(sync_syncing) while refreshing; no new string keys.

New goldens S-4-home-refreshing and S-5-account-refreshing; ViewModel tests
with GatedSyncRepository (core:design test fixtures).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:19:19 +02:00
parent 7521c0188b
commit 3778f5fa81
19 changed files with 364 additions and 32 deletions
@@ -37,6 +37,7 @@ import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittMenuContent
import se.hagstedt.kvitt.design.component.KvittMenuItem
import se.hagstedt.kvitt.design.component.KvittPullToRefresh
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LedgerMonthGroup
@@ -69,6 +70,7 @@ data class AccountDetailActions(
val onDismissDelete: () -> Unit = {},
val onRetry: (AccountAction) -> Unit = {},
val onMessageShown: () -> Unit = {},
val onRefresh: () -> Unit = {},
)
/**
@@ -120,6 +122,7 @@ fun AccountDetailScreen(
onDismissDelete = viewModel::dismissDelete,
onRetry = viewModel::retry,
onMessageShown = viewModel::messageShown,
onRefresh = viewModel::refresh,
)
AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState)
}
@@ -174,30 +177,33 @@ private fun AccountList(ui: AccountDetailUi.Ready, actions: AccountDetailActions
unarchive = stringResource(R.string.account_unarchive),
)
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
LazyColumn(
Modifier.fillMaxSize().padding(padding),
state = listState,
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
) {
item(key = "header") {
AccountBalanceHeader(ui.detail, ui.colorIndex, ui.viewerPercent, words, { actions.onSettle(account.id) }, actions.onUnarchive)
}
if (ui.detail.months.isEmpty()) {
item(key = "empty") {
EmptyState(stringResource(R.string.account_empty_title), stringResource(R.string.account_empty_body, account.name))
// Pull-to-refresh runs a sync now (D-127).
KvittPullToRefresh(ui.refreshing, actions.onRefresh, Modifier.fillMaxSize().padding(padding)) {
LazyColumn(
Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
) {
item(key = "header") {
AccountBalanceHeader(ui.detail, ui.colorIndex, ui.viewerPercent, words, { actions.onSettle(account.id) }, actions.onUnarchive)
}
}
ui.detail.months.forEach { month ->
item(key = month.period) {
LedgerMonthGroup(
month.period,
month.householdSpendOre,
month.rows,
typeLabel,
onExpense = { actions.onOpenExpense(it.id) },
onSettlement = { actions.onOpenSettlement(it.id) },
)
if (ui.detail.months.isEmpty()) {
item(key = "empty") {
EmptyState(stringResource(R.string.account_empty_title), stringResource(R.string.account_empty_body, account.name))
}
}
ui.detail.months.forEach { month ->
item(key = month.period) {
LedgerMonthGroup(
month.period,
month.householdSpendOre,
month.rows,
typeLabel,
onExpense = { actions.onOpenExpense(it.id) },
onSettlement = { actions.onOpenSettlement(it.id) },
)
}
}
}
}
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -17,6 +18,7 @@ import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
@@ -57,6 +59,7 @@ sealed interface AccountEvent {
sealed interface AccountDetailUi {
data object Starting : AccountDetailUi
/** [refreshing]: a pull-to-refresh sync is running; the indicator stays until it ends (D-127). */
data class Ready(
val detail: AccountDetailState,
val online: Boolean,
@@ -66,6 +69,7 @@ sealed interface AccountDetailUi {
val busy: Boolean = false,
val message: AccountMessage? = null,
val event: AccountEvent? = null,
val refreshing: Boolean = false,
) : AccountDetailUi {
val partner: User get() = detail.balance.partner
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: partner
@@ -90,6 +94,7 @@ private data class AccountLocal(
val busy: Boolean = false,
val message: AccountMessage? = null,
val event: AccountEvent? = null,
val refreshing: Boolean = false,
)
internal const val HALF_BP = 5_000
@@ -100,7 +105,8 @@ internal const val BP_PER_PERCENT = 100f
class AccountDetailViewModel @Inject constructor(
savedState: SavedStateHandle,
private val accounts: AccountRepository,
connectivity: ConnectivityRepository,
private val connectivity: ConnectivityRepository,
private val sync: SyncRepository,
) : ViewModel() {
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
private val local = MutableStateFlow(AccountLocal())
@@ -118,12 +124,29 @@ class AccountDetailViewModel @Inject constructor(
busy = l.busy,
message = l.message,
event = l.event,
refreshing = l.refreshing,
)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
fun setMenuOpen(open: Boolean) = local.update { it.copy(menuOpen = open) }
/**
* Pull-to-refresh (D-127): a sync now, the same call as S-16's "Synka nu"; the indicator stays until it returns,
* success or failure. Offline there is nothing to sync and the pull ends at once (§9.9).
*/
fun refresh() {
if (local.value.refreshing) return
local.update { it.copy(refreshing = true) }
viewModelScope.launch {
try {
if (connectivity.online.first()) sync.syncNow()
} finally {
local.update { it.copy(refreshing = false) }
}
}
}
fun archive() = run(AccountAction.Archive)
fun unarchive() = run(AccountAction.Unarchive)