diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Refresh.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Refresh.kt new file mode 100644 index 0000000..403ffdd --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Refresh.kt @@ -0,0 +1,131 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshState +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.design.theme.KvittMotion + +// Pull-to-refresh of Home (S-4) and account detail (S-5), D-127: pulling down at the top of the list runs a sync now. +// +// Material 3 1.4.0 has `PullToRefreshBox` (stable) but neither `LoadingIndicator` / `ContainedLoadingIndicator` nor +// `LinearWavyProgressIndicator` (Expressive, not in the 1.4.0 release), and `PullToRefreshDefaults.IndicatorBox` is +// still experimental there. The indicator is therefore Kvitt's own content in `IndicatorBox`, drawn like the hero's +// first-sync tile (design-spec §3.6 `Loading`: `primary-container` tile, Cookie9 shape with its circle fallback): the +// `sync` icon of the sync pill and "Synka nu" in `on-primary-container`, with a `primary` ring. The swap to +// `ContainedLoadingIndicator` happens here and nowhere else (design-spec §3.1). + +/** + * Pull-to-refresh around a screen's scrolling [content] (D-127). Pulling past the threshold gives a haptic tick and + * calls [onRefresh]; the indicator stays while [refreshing] is true and slides away when it turns false, whether the + * sync succeeded or not (the sync pill reports a failure, design-spec §3.6). TalkBack users keep the existing paths + * (S-16 "Synka nu"); while refreshing the indicator reads "Synkar…" as a polite live region. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KvittPullToRefresh( + refreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val state = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = onRefresh, + modifier = modifier, + state = state, + indicator = { KvittRefreshIndicator(state, refreshing, Modifier.align(Alignment.TopCenter)) }, + content = content, + ) +} + +/** + * The indicator: while pulling, the `sync` icon (at 80 %) winds with the finger and a `primary` arc fills towards the + * threshold; at the threshold the icon pops to full size (spatial fast, design-spec §2.11) with + * [HapticFeedbackType.GestureThresholdActivate]; while refreshing the arc becomes an indeterminate ring. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KvittRefreshIndicator(state: PullToRefreshState, refreshing: Boolean, modifier: Modifier) { + val scheme = MaterialTheme.colorScheme + val haptics = LocalHapticFeedback.current + val armed by remember(state) { derivedStateOf { state.distanceFraction >= 1f } } + val isRefreshing by rememberUpdatedState(refreshing) + LaunchedEffect(armed) { + if (armed && !isRefreshing) haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + val scale by animateFloatAsState( + if (armed || refreshing) 1f else KvittDimens.PULL_ICON_REST_SCALE, + KvittMotion.spatialFast(), + label = "pull icon", + ) + val spoken = stringResource(R.string.sync_syncing) + PullToRefreshDefaults.IndicatorBox( + state = state, + isRefreshing = refreshing, + modifier = modifier + .size(KvittDimens.pullTile) + .clearAndSetSemantics { + if (refreshing) { + contentDescription = spoken + liveRegion = LiveRegionMode.Polite + } + }, + shape = KvittTheme.shapes.illustration, + containerColor = scheme.primaryContainer, + ) { + val ring = Modifier.size(KvittDimens.pullRing) + if (refreshing) { + CircularProgressIndicator(ring, color = scheme.primary, strokeWidth = KvittDimens.pullRingStroke, trackColor = Color.Transparent, strokeCap = StrokeCap.Round) + } else { + CircularProgressIndicator( + progress = { state.distanceFraction.coerceIn(0f, 1f) }, + modifier = ring, + color = scheme.primary, + strokeWidth = KvittDimens.pullRingStroke, + trackColor = scheme.primary.copy(alpha = KvittDimens.PULL_TRACK_ALPHA), + strokeCap = StrokeCap.Round, + ) + } + KvittIconImage( + KvittIcon.Sync, + null, + Modifier.graphicsLayer { + rotationZ = state.distanceFraction.coerceAtLeast(0f) * KvittDimens.PULL_ICON_TURN_DEGREES + scaleX = scale + scaleY = scale + }, + size = KvittDimens.pullIcon, + tint = scheme.onPrimaryContainer, + ) + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt index 142797a..6c3f298 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt @@ -99,6 +99,16 @@ object KvittDimens { val bannerGap = 6.dp val hintVertical = 4.dp + // Pull-to-refresh on S-4 and S-5 (D-127): a 48 dp tile with a 32 dp ring (stroke 3) around the 18 dp `sync` icon. + val pullTile = 48.dp + val pullRing = 32.dp + val pullRingStroke = 3.dp + val pullIcon = 18.dp + /** The icon grows from 80 % to full size as the pull reaches its threshold, and turns 270° on the way. */ + const val PULL_ICON_REST_SCALE = 0.8f + const val PULL_ICON_TURN_DEGREES = 270f + const val PULL_TRACK_ALPHA = 0.24f + // Editor fields (§3.7). val amountTop = 10.dp val amountBottom = 6.dp diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt index 6d417d7..2e8d870 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt @@ -18,4 +18,10 @@ object KvittMotion { const val SEESAW_STIFFNESS = 300f fun seesaw(): FiniteAnimationSpec = spring(SEESAW_DAMPING, SEESAW_STIFFNESS) + + /** Spatial fast: chip and tile selection, check badge pop, the pull-to-refresh icon at its threshold (D-127). */ + const val SPATIAL_FAST_DAMPING = 0.6f + const val SPATIAL_FAST_STIFFNESS = 800f + + fun spatialFast(): FiniteAnimationSpec = spring(SPATIAL_FAST_DAMPING, SPATIAL_FAST_STIFFNESS) } diff --git a/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/GatedSyncRepository.kt b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/GatedSyncRepository.kt new file mode 100644 index 0000000..3a3ec0b --- /dev/null +++ b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/GatedSyncRepository.kt @@ -0,0 +1,27 @@ +package se.hagstedt.kvitt.design.testing + +import kotlinx.coroutines.CompletableDeferred +import se.hagstedt.kvitt.model.SyncRepository + +/** + * A [SyncRepository] whose `syncNow()` waits until the test calls [finish], then runs [delegate]'s; it counts the + * calls. Lets a test see a screen while its sync is still running (pull-to-refresh, D-127); no mocking library. + */ +class GatedSyncRepository(private val delegate: SyncRepository) : SyncRepository by delegate { + private val gate = CompletableDeferred() + + /** How many times `syncNow()` was called. */ + var calls: Int = 0 + private set + + override suspend fun syncNow() { + calls += 1 + gate.await() + delegate.syncNow() + } + + /** Lets every waiting and later `syncNow()` run to its end. */ + fun finish() { + gate.complete(Unit) + } +} diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt index 26c3fe7..9ff7074 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt @@ -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) }, + ) + } } } } diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt index 68d9aaa..9b6cb4a 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt @@ -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(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) diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt index 8ab22a8..2442c4a 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt @@ -31,6 +31,7 @@ val accountStates: List = 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> = 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", diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt index 670f4f4..186fc91 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt @@ -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 } diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt index cd5ad72..caad181 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt @@ -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"))) diff --git a/android/feature/account/src/test/screenshots/S-5-account-refreshing_en_light.png b/android/feature/account/src/test/screenshots/S-5-account-refreshing_en_light.png new file mode 100644 index 0000000..e23e7b5 Binary files /dev/null and b/android/feature/account/src/test/screenshots/S-5-account-refreshing_en_light.png differ diff --git a/android/feature/account/src/test/screenshots/S-5-account-refreshing_sv_dark.png b/android/feature/account/src/test/screenshots/S-5-account-refreshing_sv_dark.png new file mode 100644 index 0000000..b365e97 Binary files /dev/null and b/android/feature/account/src/test/screenshots/S-5-account-refreshing_sv_dark.png differ diff --git a/android/feature/account/src/test/screenshots/S-5-account-refreshing_sv_light.png b/android/feature/account/src/test/screenshots/S-5-account-refreshing_sv_light.png new file mode 100644 index 0000000..c9fee50 Binary files /dev/null and b/android/feature/account/src/test/screenshots/S-5-account-refreshing_sv_light.png differ diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt index d42a247..6213c08 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt @@ -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) { + 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), diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt index 1421c4f..589f809 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModel.kt @@ -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 = 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 diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt index 53f37b7..c1efa49 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt @@ -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 = 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> = 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", diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt index 0a7ab88..32cb98a 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt @@ -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() } diff --git a/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png b/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png new file mode 100644 index 0000000..0a3b966 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-4-home-refreshing_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png new file mode 100644 index 0000000..20e46a4 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png new file mode 100644 index 0000000..ad873a3 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-4-home-refreshing_sv_light.png differ