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:
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,16 @@ object KvittDimens {
|
|||||||
val bannerGap = 6.dp
|
val bannerGap = 6.dp
|
||||||
val hintVertical = 4.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).
|
// Editor fields (§3.7).
|
||||||
val amountTop = 10.dp
|
val amountTop = 10.dp
|
||||||
val amountBottom = 6.dp
|
val amountBottom = 6.dp
|
||||||
|
|||||||
@@ -18,4 +18,10 @@ object KvittMotion {
|
|||||||
const val SEESAW_STIFFNESS = 300f
|
const val SEESAW_STIFFNESS = 300f
|
||||||
|
|
||||||
fun <T> seesaw(): FiniteAnimationSpec<T> = spring(SEESAW_DAMPING, SEESAW_STIFFNESS)
|
fun <T> seesaw(): FiniteAnimationSpec<T> = 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 <T> spatialFast(): FiniteAnimationSpec<T> = spring(SPATIAL_FAST_DAMPING, SPATIAL_FAST_STIFFNESS)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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<Unit>()
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.KvittIconImage
|
||||||
import se.hagstedt.kvitt.design.component.KvittMenuContent
|
import se.hagstedt.kvitt.design.component.KvittMenuContent
|
||||||
import se.hagstedt.kvitt.design.component.KvittMenuItem
|
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.KvittScaffold
|
||||||
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
||||||
import se.hagstedt.kvitt.design.component.LedgerMonthGroup
|
import se.hagstedt.kvitt.design.component.LedgerMonthGroup
|
||||||
@@ -69,6 +70,7 @@ data class AccountDetailActions(
|
|||||||
val onDismissDelete: () -> Unit = {},
|
val onDismissDelete: () -> Unit = {},
|
||||||
val onRetry: (AccountAction) -> Unit = {},
|
val onRetry: (AccountAction) -> Unit = {},
|
||||||
val onMessageShown: () -> Unit = {},
|
val onMessageShown: () -> Unit = {},
|
||||||
|
val onRefresh: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +122,7 @@ fun AccountDetailScreen(
|
|||||||
onDismissDelete = viewModel::dismissDelete,
|
onDismissDelete = viewModel::dismissDelete,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onMessageShown = viewModel::messageShown,
|
onMessageShown = viewModel::messageShown,
|
||||||
|
onRefresh = viewModel::refresh,
|
||||||
)
|
)
|
||||||
AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState)
|
AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState)
|
||||||
}
|
}
|
||||||
@@ -174,30 +177,33 @@ private fun AccountList(ui: AccountDetailUi.Ready, actions: AccountDetailActions
|
|||||||
unarchive = stringResource(R.string.account_unarchive),
|
unarchive = stringResource(R.string.account_unarchive),
|
||||||
)
|
)
|
||||||
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
|
val typeLabel = stringResource(DesignR.string.ledger_settlement_label)
|
||||||
LazyColumn(
|
// Pull-to-refresh runs a sync now (D-127).
|
||||||
Modifier.fillMaxSize().padding(padding),
|
KvittPullToRefresh(ui.refreshing, actions.onRefresh, Modifier.fillMaxSize().padding(padding)) {
|
||||||
state = listState,
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
|
Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
|
state = listState,
|
||||||
) {
|
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
|
||||||
item(key = "header") {
|
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
|
||||||
AccountBalanceHeader(ui.detail, ui.colorIndex, ui.viewerPercent, words, { actions.onSettle(account.id) }, actions.onUnarchive)
|
) {
|
||||||
}
|
item(key = "header") {
|
||||||
if (ui.detail.months.isEmpty()) {
|
AccountBalanceHeader(ui.detail, ui.colorIndex, ui.viewerPercent, words, { actions.onSettle(account.id) }, actions.onUnarchive)
|
||||||
item(key = "empty") {
|
|
||||||
EmptyState(stringResource(R.string.account_empty_title), stringResource(R.string.account_empty_body, account.name))
|
|
||||||
}
|
}
|
||||||
}
|
if (ui.detail.months.isEmpty()) {
|
||||||
ui.detail.months.forEach { month ->
|
item(key = "empty") {
|
||||||
item(key = month.period) {
|
EmptyState(stringResource(R.string.account_empty_title), stringResource(R.string.account_empty_body, account.name))
|
||||||
LedgerMonthGroup(
|
}
|
||||||
month.period,
|
}
|
||||||
month.householdSpendOre,
|
ui.detail.months.forEach { month ->
|
||||||
month.rows,
|
item(key = month.period) {
|
||||||
typeLabel,
|
LedgerMonthGroup(
|
||||||
onExpense = { actions.onOpenExpense(it.id) },
|
month.period,
|
||||||
onSettlement = { actions.onOpenSettlement(it.id) },
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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.AccountRepository
|
||||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||||
import se.hagstedt.kvitt.model.OnlineResult
|
import se.hagstedt.kvitt.model.OnlineResult
|
||||||
|
import se.hagstedt.kvitt.model.SyncRepository
|
||||||
import se.hagstedt.kvitt.model.User
|
import se.hagstedt.kvitt.model.User
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ sealed interface AccountEvent {
|
|||||||
sealed interface AccountDetailUi {
|
sealed interface AccountDetailUi {
|
||||||
data object Starting : AccountDetailUi
|
data object Starting : AccountDetailUi
|
||||||
|
|
||||||
|
/** [refreshing]: a pull-to-refresh sync is running; the indicator stays until it ends (D-127). */
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val detail: AccountDetailState,
|
val detail: AccountDetailState,
|
||||||
val online: Boolean,
|
val online: Boolean,
|
||||||
@@ -66,6 +69,7 @@ sealed interface AccountDetailUi {
|
|||||||
val busy: Boolean = false,
|
val busy: Boolean = false,
|
||||||
val message: AccountMessage? = null,
|
val message: AccountMessage? = null,
|
||||||
val event: AccountEvent? = null,
|
val event: AccountEvent? = null,
|
||||||
|
val refreshing: Boolean = false,
|
||||||
) : AccountDetailUi {
|
) : AccountDetailUi {
|
||||||
val partner: User get() = detail.balance.partner
|
val partner: User get() = detail.balance.partner
|
||||||
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: 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 busy: Boolean = false,
|
||||||
val message: AccountMessage? = null,
|
val message: AccountMessage? = null,
|
||||||
val event: AccountEvent? = null,
|
val event: AccountEvent? = null,
|
||||||
|
val refreshing: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal const val HALF_BP = 5_000
|
internal const val HALF_BP = 5_000
|
||||||
@@ -100,7 +105,8 @@ internal const val BP_PER_PERCENT = 100f
|
|||||||
class AccountDetailViewModel @Inject constructor(
|
class AccountDetailViewModel @Inject constructor(
|
||||||
savedState: SavedStateHandle,
|
savedState: SavedStateHandle,
|
||||||
private val accounts: AccountRepository,
|
private val accounts: AccountRepository,
|
||||||
connectivity: ConnectivityRepository,
|
private val connectivity: ConnectivityRepository,
|
||||||
|
private val sync: SyncRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
|
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
|
||||||
private val local = MutableStateFlow(AccountLocal())
|
private val local = MutableStateFlow(AccountLocal())
|
||||||
@@ -118,12 +124,29 @@ class AccountDetailViewModel @Inject constructor(
|
|||||||
busy = l.busy,
|
busy = l.busy,
|
||||||
message = l.message,
|
message = l.message,
|
||||||
event = l.event,
|
event = l.event,
|
||||||
|
refreshing = l.refreshing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
|
||||||
|
|
||||||
fun setMenuOpen(open: Boolean) = local.update { it.copy(menuOpen = open) }
|
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 archive() = run(AccountAction.Archive)
|
||||||
|
|
||||||
fun unarchive() = run(AccountAction.Unarchive)
|
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-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-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = FAILURE_NETWORK) { it.archive() },
|
||||||
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
|
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-create", AccountScenarios.editor),
|
||||||
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
|
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),
|
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()) }
|
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 {
|
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)
|
val ui = editorState(scenario, id, failWith, act = act)
|
||||||
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
|
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
|
||||||
@@ -80,6 +87,7 @@ class AccountScreenshotTest(private val state: String) {
|
|||||||
fun states(): List<Array<Any>> = listOf(
|
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", "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-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-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-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
|
||||||
"S-6-account-editor-saving",
|
"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 {
|
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
|
||||||
val fakes = FakeRepositories(scenario)
|
val fakes = FakeRepositories(scenario)
|
||||||
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
|
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)
|
act(vm)
|
||||||
vm.ui.value
|
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.FakeScenarios
|
||||||
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||||
import se.hagstedt.kvitt.model.AccountId
|
import se.hagstedt.kvitt.model.AccountId
|
||||||
|
import se.hagstedt.kvitt.design.testing.GatedSyncRepository
|
||||||
import se.hagstedt.kvitt.design.testing.withMain
|
import se.hagstedt.kvitt.design.testing.withMain
|
||||||
|
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||||
|
import se.hagstedt.kvitt.model.LedgerRow
|
||||||
|
|
||||||
class AccountDetailViewModelTest {
|
class AccountDetailViewModelTest {
|
||||||
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
|
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
|
||||||
@@ -102,6 +105,40 @@ class AccountDetailViewModelTest {
|
|||||||
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
|
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
|
@Test
|
||||||
fun unknownAccountIsGone() {
|
fun unknownAccountIsGone() {
|
||||||
assertEquals(AccountDetailUi.Gone(AccountEvent.Gone), detailState(AccountScenarios.loaded, AccountId("missing")))
|
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.KvittCard
|
||||||
import se.hagstedt.kvitt.design.component.KvittDestination
|
import se.hagstedt.kvitt.design.component.KvittDestination
|
||||||
import se.hagstedt.kvitt.design.component.KvittNavigationBar
|
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.KvittScaffold
|
||||||
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
||||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||||
@@ -76,6 +77,7 @@ data class HomeActions(
|
|||||||
val onUpdateRetry: () -> Unit = {},
|
val onUpdateRetry: () -> Unit = {},
|
||||||
val onInstallContinue: () -> Unit = {},
|
val onInstallContinue: () -> Unit = {},
|
||||||
val onInstallDismiss: () -> Unit = {},
|
val onInstallDismiss: () -> Unit = {},
|
||||||
|
val onRefresh: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +119,7 @@ fun HomeScreen(
|
|||||||
onUpdateRetry = viewModel::onRetry,
|
onUpdateRetry = viewModel::onRetry,
|
||||||
onInstallContinue = viewModel::onInstallDialogContinue,
|
onInstallContinue = viewModel::onInstallDialogContinue,
|
||||||
onInstallDismiss = viewModel::onInstallDialogDismiss,
|
onInstallDismiss = viewModel::onInstallDialogDismiss,
|
||||||
|
onRefresh = viewModel::refresh,
|
||||||
)
|
)
|
||||||
HomeContent(ui, actions, modifier, snackbarHostState, minute)
|
HomeContent(ui, actions, modifier, snackbarHostState, minute)
|
||||||
}
|
}
|
||||||
@@ -157,15 +160,23 @@ fun HomeContent(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padding: PaddingValues, tick: Int, listState: LazyListState) {
|
private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padding: PaddingValues, tick: Int, listState: LazyListState) {
|
||||||
val spacing = KvittTheme.spacing
|
|
||||||
val words = HeroWords(
|
val words = HeroWords(
|
||||||
settleUp = stringResource(R.string.home_settle_up),
|
settleUp = stringResource(R.string.home_settle_up),
|
||||||
loadingTitle = stringResource(R.string.home_loading),
|
loadingTitle = stringResource(R.string.home_loading),
|
||||||
loadingBody = stringResource(R.string.home_loading_body),
|
loadingBody = stringResource(R.string.home_loading_body),
|
||||||
)
|
)
|
||||||
val colorIndex = ui.colorIndex
|
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(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize().padding(padding),
|
Modifier.fillMaxSize(),
|
||||||
state = listState,
|
state = listState,
|
||||||
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
|
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.fabClearance),
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
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.ConnectivityRepository
|
||||||
import se.hagstedt.kvitt.model.HomeRepository
|
import se.hagstedt.kvitt.model.HomeRepository
|
||||||
import se.hagstedt.kvitt.model.HomeState
|
import se.hagstedt.kvitt.model.HomeState
|
||||||
|
import se.hagstedt.kvitt.model.SyncRepository
|
||||||
import se.hagstedt.kvitt.model.UpdateRepository
|
import se.hagstedt.kvitt.model.UpdateRepository
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import javax.inject.Inject
|
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 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 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 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(
|
data class Ready(
|
||||||
val home: HomeState,
|
val home: HomeState,
|
||||||
@@ -42,6 +45,7 @@ sealed interface HomeUi {
|
|||||||
val installDialog: Boolean,
|
val installDialog: Boolean,
|
||||||
val loading: Boolean,
|
val loading: Boolean,
|
||||||
val illustration: List<Category> = emptyList(),
|
val illustration: List<Category> = emptyList(),
|
||||||
|
val refreshing: Boolean = false,
|
||||||
) : HomeUi {
|
) : HomeUi {
|
||||||
/** No account at all: the empty state, no sync pill, no FAB (AC-8.6 needs an account). */
|
/** 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()
|
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. */
|
/** 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)
|
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). */
|
/** State holder of Home (S-4) and its update banner and dialog (S-17). */
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject constructor(
|
class HomeViewModel @Inject constructor(
|
||||||
home: HomeRepository,
|
home: HomeRepository,
|
||||||
connectivity: ConnectivityRepository,
|
private val connectivity: ConnectivityRepository,
|
||||||
private val updates: UpdateRepository,
|
private val updates: UpdateRepository,
|
||||||
categories: CategoryRepository,
|
categories: CategoryRepository,
|
||||||
|
private val sync: SyncRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val local = MutableStateFlow(HomeLocal())
|
private val local = MutableStateFlow(HomeLocal())
|
||||||
|
|
||||||
@@ -90,11 +100,28 @@ class HomeViewModel @Inject constructor(
|
|||||||
installDialog = l.installDialog && update is UpdateState.NeedsInstallPermission,
|
installDialog = l.installDialog && update is UpdateState.NeedsInstallPermission,
|
||||||
loading = state.sync.lastSuccessAt == null,
|
loading = state.sync.lastSuccessAt == null,
|
||||||
illustration = tiles,
|
illustration = tiles,
|
||||||
|
refreshing = l.refreshing,
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUi.Starting)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUi.Starting)
|
||||||
|
|
||||||
fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) }
|
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. */
|
/** "Uppdatera": the first time the explanation dialog (AC-16.3), otherwise the download and install. */
|
||||||
fun onUpdate() {
|
fun onUpdate() {
|
||||||
val current = (ui.value as? HomeUi.Ready)?.update
|
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. */
|
/** Home with [scenario] after [act] on its ViewModel. */
|
||||||
fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi = withMain {
|
fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi = withMain {
|
||||||
val fakes = FakeRepositories(scenario)
|
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)
|
act(vm)
|
||||||
vm.ui.value
|
vm.ui.value
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,7 @@ val homeStates: List<ScreenState> = listOf(
|
|||||||
home("S-4-home-offline", HomeScenarios.offline),
|
home("S-4-home-offline", HomeScenarios.offline),
|
||||||
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
|
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
|
||||||
home("S-4-home-long", HomeScenarios.long, largeFont = true),
|
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-banner", HomeScenarios.updateAvailable),
|
||||||
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
|
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
|
||||||
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
|
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()) }
|
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 {
|
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (SyncStatusViewModel) -> Unit = {}): ScreenState {
|
||||||
val ui = syncState(scenario, act)
|
val ui = syncState(scenario, act)
|
||||||
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
|
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
|
||||||
@@ -98,6 +105,7 @@ class HomeScreenshotTest(private val state: String) {
|
|||||||
fun states(): List<Array<Any>> = listOf(
|
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", "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-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-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", "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",
|
"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.UpdateCheck
|
||||||
import se.hagstedt.kvitt.model.UpdateRepository
|
import se.hagstedt.kvitt.model.UpdateRepository
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
|
import se.hagstedt.kvitt.design.testing.GatedSyncRepository
|
||||||
import se.hagstedt.kvitt.design.testing.withMain
|
import se.hagstedt.kvitt.design.testing.withMain
|
||||||
|
|
||||||
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
|
/** 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 {
|
private fun vm(scenario: FakeScenario, updates: UpdateRepository): HomeViewModel = withMain {
|
||||||
val fakes = FakeRepositories(scenario)
|
val fakes = FakeRepositories(scenario)
|
||||||
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories)
|
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories, fakes.sync)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -122,6 +123,43 @@ class HomeViewModelTest {
|
|||||||
assertEquals(2, updates.installs)
|
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
|
@Test
|
||||||
fun dialogDismissKeepsTheBanner() {
|
fun dialogDismissKeepsTheBanner() {
|
||||||
val ui = ready(HomeScenarios.updateNeedsPermission) { it.onUpdate(); it.onInstallDialogDismiss() }
|
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