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 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
|
||||
|
||||
@@ -18,4 +18,10 @@ object KvittMotion {
|
||||
const val SEESAW_STIFFNESS = 300f
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user