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:
+29
-23
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -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)
|
||||
|
||||
+8
@@ -31,6 +31,7 @@ val accountStates: List<ScreenState> = listOf(
|
||||
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = FAILURE_NETWORK) { it.archive() },
|
||||
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
|
||||
refreshing("S-5-account-refreshing", AccountScenarios.loaded, AccountScenarios.vardag),
|
||||
editor("S-6-account-editor-create", AccountScenarios.editor),
|
||||
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
|
||||
editor("S-6-account-editor-edit", AccountScenarios.editor, id = AccountScenarios.huset),
|
||||
@@ -52,6 +53,12 @@ private fun detail(name: String, scenario: FakeScenario, id: AccountId, failWith
|
||||
return ScreenState(name, largeFont) { AccountDetailContent(ui, AccountDetailActions()) }
|
||||
}
|
||||
|
||||
/** The account screen while a pull-to-refresh sync runs (D-127): the indicator is held at its resting place. */
|
||||
private fun refreshing(name: String, scenario: FakeScenario, id: AccountId): ScreenState {
|
||||
val ui = (detailState(scenario, id) as AccountDetailUi.Ready).copy(refreshing = true)
|
||||
return ScreenState(name) { AccountDetailContent(ui, AccountDetailActions()) }
|
||||
}
|
||||
|
||||
private fun editor(name: String, scenario: FakeScenario, id: AccountId? = null, failWith: String? = null, largeFont: Boolean = false, act: (AccountEditorViewModel) -> Unit = {}): ScreenState {
|
||||
val ui = editorState(scenario, id, failWith, act = act)
|
||||
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
|
||||
@@ -80,6 +87,7 @@ class AccountScreenshotTest(private val state: String) {
|
||||
fun states(): List<Array<Any>> = listOf(
|
||||
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
|
||||
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
|
||||
"S-5-account-refreshing",
|
||||
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
|
||||
"S-6-account-editor-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
|
||||
"S-6-account-editor-saving",
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) em
|
||||
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
|
||||
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity)
|
||||
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity, fakes.sync)
|
||||
act(vm)
|
||||
vm.ui.value
|
||||
}
|
||||
|
||||
+37
@@ -10,7 +10,10 @@ import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.design.testing.GatedSyncRepository
|
||||
import se.hagstedt.kvitt.design.testing.withMain
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.model.LedgerRow
|
||||
|
||||
class AccountDetailViewModelTest {
|
||||
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
|
||||
@@ -102,6 +105,40 @@ class AccountDetailViewModelTest {
|
||||
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
|
||||
}
|
||||
|
||||
/** S-5 for Vardag in [scenario] whose sync waits for the test (pull-to-refresh, D-127); runs [block] with Main set. */
|
||||
private fun withGatedSync(scenario: FakeScenario, block: (AccountDetailViewModel, GatedSyncRepository) -> Unit) = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val sync = GatedSyncRepository(fakes.sync)
|
||||
block(AccountDetailViewModel(args(AccountScenarios.vardag), fakes.accounts, fakes.connectivity, sync), sync)
|
||||
}
|
||||
|
||||
private fun AccountDetailUi.Ready.pendingRows(): Int = detail.months.flatMap { it.rows }.count {
|
||||
when (it) {
|
||||
is LedgerRow.ExpenseRow -> it.pending
|
||||
is LedgerRow.SettlementRow -> it.pending
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pullToRefreshSyncsAndStaysUntilTheSyncEnds() = withGatedSync(AccountScenarios.loaded) { vm, sync ->
|
||||
assertTrue(ready(vm.ui.value).pendingRows() > 0)
|
||||
vm.refresh()
|
||||
assertTrue(ready(vm.ui.value).refreshing)
|
||||
vm.refresh()
|
||||
assertEquals(1, sync.calls)
|
||||
sync.finish()
|
||||
val ui = ready(vm.ui.value)
|
||||
assertFalse(ui.refreshing)
|
||||
assertEquals(0, ui.pendingRows())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pullToRefreshOfflineEndsAtOnceWithoutSyncing() = withGatedSync(AccountScenarios.offline) { vm, sync ->
|
||||
vm.refresh()
|
||||
assertFalse(ready(vm.ui.value).refreshing)
|
||||
assertEquals(0, sync.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownAccountIsGone() {
|
||||
assertEquals(AccountDetailUi.Gone(AccountEvent.Gone), detailState(AccountScenarios.loaded, AccountId("missing")))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Reference in New Issue
Block a user