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
@@ -20,7 +20,7 @@ class ScreenState(val name: String, val largeFont: Boolean = false, val content:
/** 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)
val vm = HomeViewModel(fakes.home, fakes.connectivity, fakes.update, fakes.categories, fakes.sync)
act(vm)
vm.ui.value
}
@@ -47,6 +47,7 @@ val homeStates: List<ScreenState> = listOf(
home("S-4-home-offline", HomeScenarios.offline),
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
home("S-4-home-long", HomeScenarios.long, largeFont = true),
refreshing("S-4-home-refreshing", HomeScenarios.pending),
home("S-17-update-banner", HomeScenarios.updateAvailable),
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
@@ -70,6 +71,12 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
/** Home while a pull-to-refresh sync runs (D-127): the indicator is held at its resting place. */
private fun refreshing(name: String, scenario: FakeScenario): ScreenState {
val ui = (homeState(scenario) as HomeUi.Ready).copy(refreshing = true)
return ScreenState(name) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (SyncStatusViewModel) -> Unit = {}): ScreenState {
val ui = syncState(scenario, act)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
@@ -98,6 +105,7 @@ class HomeScreenshotTest(private val state: String) {
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-4-home-refreshing",
"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", "S-16-sync-lost", "S-16-sync-lost-payment", "S-16-sync-lost-deleted",
@@ -14,6 +14,7 @@ import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.design.testing.GatedSyncRepository
import se.hagstedt.kvitt.design.testing.withMain
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
@@ -32,7 +33,7 @@ class HomeViewModelTest {
private fun vm(scenario: FakeScenario, updates: UpdateRepository): HomeViewModel = withMain {
val fakes = FakeRepositories(scenario)
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories)
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories, fakes.sync)
}
@Test
@@ -122,6 +123,43 @@ class HomeViewModelTest {
assertEquals(2, updates.installs)
}
/** Home over [scenario] whose sync waits for the test (pull-to-refresh, D-127); runs [block] with Main set. */
private fun withGatedSync(scenario: FakeScenario, block: (HomeViewModel, GatedSyncRepository) -> Unit) = withMain {
val fakes = FakeRepositories(scenario)
val sync = GatedSyncRepository(fakes.sync)
block(HomeViewModel(fakes.home, fakes.connectivity, fakes.update, fakes.categories, sync), sync)
}
@Test
fun pullToRefreshSyncsAndStaysUntilTheSyncEnds() = withGatedSync(HomeScenarios.pending) { vm, sync ->
assertEquals(2, (vm.ui.value as HomeUi.Ready).home.sync.pendingCount)
vm.refresh()
assertTrue((vm.ui.value as HomeUi.Ready).refreshing)
vm.refresh()
assertEquals(1, sync.calls)
sync.finish()
val ui = vm.ui.value as HomeUi.Ready
assertFalse(ui.refreshing)
assertEquals(0, ui.home.sync.pendingCount)
}
@Test
fun pullToRefreshEndsWhenTheSyncFails() = withGatedSync(HomeScenarios.clockSkew) { vm, sync ->
vm.refresh()
assertTrue((vm.ui.value as HomeUi.Ready).refreshing)
sync.finish()
val ui = vm.ui.value as HomeUi.Ready
assertFalse(ui.refreshing)
assertEquals(2, ui.home.sync.pendingCount)
}
@Test
fun pullToRefreshOfflineEndsAtOnceWithoutSyncing() = withGatedSync(HomeScenarios.offline) { vm, sync ->
vm.refresh()
assertFalse((vm.ui.value as HomeUi.Ready).refreshing)
assertEquals(0, sync.calls)
}
@Test
fun dialogDismissKeepsTheBanner() {
val ui = ready(HomeScenarios.updateNeedsPermission) { it.onUpdate(); it.onInstallDialogDismiss() }
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB