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:
@@ -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),
|
||||
|
||||
+30
-3
@@ -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
|
||||
|
||||
+9
-1
@@ -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",
|
||||
|
||||
+39
-1
@@ -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 |
Reference in New Issue
Block a user