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)
@@ -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",
@@ -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
}
@@ -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

@@ -43,6 +43,7 @@ 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.KvittPullToRefresh
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
@@ -76,6 +77,7 @@ data class HomeActions(
val onUpdateRetry: () -> Unit = {},
val onInstallContinue: () -> Unit = {},
val onInstallDismiss: () -> Unit = {},
val onRefresh: () -> Unit = {},
)
/**
@@ -117,6 +119,7 @@ fun HomeScreen(
onUpdateRetry = viewModel::onRetry,
onInstallContinue = viewModel::onInstallDialogContinue,
onInstallDismiss = viewModel::onInstallDialogDismiss,
onRefresh = viewModel::refresh,
)
HomeContent(ui, actions, modifier, snackbarHostState, minute)
}
@@ -157,15 +160,23 @@ fun HomeContent(
@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
// Pull-to-refresh runs a sync now (D-127); the list keeps its own states (loading, empty, loaded) inside it.
KvittPullToRefresh(ui.refreshing, actions.onRefresh, Modifier.fillMaxSize().padding(padding)) {
HomeItems(ui, partner, actions, tick, listState, words, colorIndex)
}
}
@Composable
private fun HomeItems(ui: HomeUi.Ready, partner: User, actions: HomeActions, tick: Int, listState: LazyListState, words: HeroWords, colorIndex: Map<String, Int>) {
val spacing = KvittTheme.spacing
LazyColumn(
Modifier.fillMaxSize().padding(padding),
Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
@@ -7,6 +7,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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -18,6 +19,7 @@ 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.SyncRepository
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import javax.inject.Inject
@@ -33,6 +35,7 @@ sealed interface HomeUi {
* @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).
* @property refreshing a pull-to-refresh sync is running: the indicator stays until it ends (D-127).
*/
data class Ready(
val home: HomeState,
@@ -42,6 +45,7 @@ sealed interface HomeUi {
val installDialog: Boolean,
val loading: Boolean,
val illustration: List<Category> = emptyList(),
val refreshing: Boolean = false,
) : 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()
@@ -64,16 +68,22 @@ sealed interface HomeUi {
}
}
/** 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)
/** Session-scoped choices on Home: the archived section, a hidden update banner, the install dialog, a running pull. */
private data class HomeLocal(
val archivedExpanded: Boolean = false,
val updateHidden: Boolean = false,
val installDialog: Boolean = false,
val refreshing: 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 connectivity: ConnectivityRepository,
private val updates: UpdateRepository,
categories: CategoryRepository,
private val sync: SyncRepository,
) : ViewModel() {
private val local = MutableStateFlow(HomeLocal())
@@ -90,11 +100,28 @@ class HomeViewModel @Inject constructor(
installDialog = l.installDialog && update is UpdateState.NeedsInstallPermission,
loading = state.sync.lastSuccessAt == null,
illustration = tiles,
refreshing = l.refreshing,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUi.Starting)
fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) }
/**
* 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) }
}
}
}
/** "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
@@ -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