package com.peachpasswords.ui.settings import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Velocity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.material3.Text import androidx.compose.ui.text.style.TextAlign import com.peachpasswords.BuildConfig import com.peachpasswords.ui.components.PeachTopBar import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.LocalReduceMotion import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachMotion import com.peachpasswords.ui.theme.PeachTheme import com.peachpasswords.ui.theme.PeachStackColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import kotlinx.coroutines.delay import kotlinx.coroutines.launch // Deck geometry — ported verbatim from StackDeck.tsx constants. The active card // sits at top:0; every other card is a thin tab fanned below it, ordered // cyclically starting just after the active card so the stack reads as a loop. private val EXPANDED_H = 258.dp private val TAB_H = 46.dp private val TAB_OVERLAP = 14.dp private val TAB_CARD_H = TAB_H + 4.dp /** * Vertical room the build label needs at the bottom corner, excluding its * navigation-bar inset (measured live, since it varies with gesture vs * three-button nav): its bottom padding plus roughly one line of 9sp mono. */ private val VERSION_LABEL_FOOTPRINT = PeachSpacing.xs + 12.dp /** Clean gap left between the lowest fanned card and the build label. */ private val DECK_TO_VERSION_GAP = 1.dp /** Drag distance past which a stack card flips between focused and deck states. */ private val DRAG_THRESHOLD = 60.dp /** Outcome of releasing a vertical drag on a stack card. */ internal enum class DragOutcome { EXPAND, COLLAPSE, NONE } /** * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed * (deck) card up past the threshold focuses it; dragging the focused card down * past the threshold returns to the deck. Anything short of the threshold is a no-op. */ internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when { dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE else -> DragOutcome.NONE } /** * Fixed vertical stride between consecutive collapsed tabs — browser parity * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`). * The deck reads as a tight physical stack fanned just below the active card, not * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP]. */ private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP /** * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance] * from the active card. Mirrors the browser's deck-open layout so positions match * one-to-one. The active card sits at 0; the first fanned tab sits just under the * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE]. */ internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp = expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance /** * The deck fills the available height down to just above the build label pinned * to the bottom corner. [navBarBottom] is the label's navigation-bar inset, * measured live by the caller (it varies with gesture vs three-button nav); here * we additionally reserve the label's own padding/line height * ([VERSION_LABEL_FOOTPRINT]) and a small visual gap ([DECK_TO_VERSION_GAP]) so * the lowest fanned card sits cleanly above the label on every screen — phone or * Fold. A previous fixed gutter ignored the nav inset, so it stranded a band of * dead space below the stack (and clipped the Pro card's copy/reveal buttons) * wherever the two didn't happen to line up. */ internal fun settingsDeckHeight(availableHeight: Dp, navBarBottom: Dp): Dp { val reserved = navBarBottom + VERSION_LABEL_FOOTPRINT + DECK_TO_VERSION_GAP return (availableHeight - reserved).coerceAtLeast(0.dp) } @Composable fun SettingsScreen( onBack: () -> Unit, onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {}, vm: SettingsViewModel = hiltViewModel(), ) { val entries by vm.entries.collectAsState() val settings by vm.settings.collectAsState() val isPro by vm.isPro.collectAsState() val showCelebration by vm.showCelebration.collectAsState() val reduceMotion = LocalReduceMotion.current val accent = LocalPeachAccent.current val scope = rememberCoroutineScope() var active by remember { mutableStateOf(CardId.Pro) } var deckOpen by remember { mutableStateOf(true) } var fanning by remember { mutableStateOf(false) } var celebrating by remember { mutableStateOf(null) } val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0) fun handleBack() { if (!deckOpen) deckOpen = true else onBack() } BackHandler { handleBack() } // Pro celebration: replay every card in a quick fan, land back on Pro, shake. fun celebrate() { if (fanning) return scope.launch { fanning = true active = CardId.Pro CELEBRATION_FAN.forEachIndexed { i, id -> delay(150L) active = id } delay(150L) active = CardId.Pro fanning = false celebrating = CardId.Pro // Matches browser's PRO_CELEBRATION_THANK_YOU_MS (Settings.tsx) — the // window the spark burst + "thank you" text stay mounted for. delay(3200L) celebrating = null } } PeachTheme(accent = accent, reduceMotion = reduceMotion) { ShellBackground { Column( Modifier .fillMaxSize() .statusBarsPadding() .padding(horizontal = PeachSpacing.md), ) { SettingsTopBar( activeIndex = activeIndex, total = STACK_CARDS.size, onBack = { handleBack() }, ) Spacer(Modifier.height(PeachSpacing.sm)) BoxWithConstraints(Modifier.fillMaxSize()) { val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val deckHeight = settingsDeckHeight(maxHeight, navBarBottom) val focusHeight = deckHeight - 8.dp // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal // browser-popup constant) for the active card's preview height, // leaving the same dead space below the fanned tabs on a phone // screen that settingsDeckHeight itself was just fixed for. Size it // so the active preview + all 6 fanned tabs together fill deckHeight, // never shrinking below the original browser-derived minimum. val expandedH = ( deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H ).coerceAtLeast(EXPANDED_H) Box(Modifier.fillMaxWidth().height(deckHeight)) { STACK_CARDS.forEachIndexed { index, card -> val isActive = card.id == active val distance = when { index == activeIndex -> -1 index > activeIndex -> index - activeIndex - 1 else -> STACK_CARDS.size - activeIndex + index - 1 } val targetTop = when { isActive -> 0.dp !deckOpen -> focusHeight + 44.dp + (distance * 8).dp else -> collapsedTabTop(distance, expandedH) } val targetHeight = when { isActive && deckOpen -> expandedH isActive -> focusHeight else -> TAB_CARD_H } val concealed = !deckOpen && !isActive // The drag-to-focus/collapse gesture is a continuous, interruptible // user interaction (not a one-shot navigation transition), so it gets // real spring physics here rather than the fixed-duration tween used // for the one-shot celebration fan — matching PeachExpandableCard and // current M3 Expressive's physics-based spatial-spring motion model. val stackSpringDp = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy) val stackSpringFloat = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy) val top by animateDpAsState( targetTop, animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp, label = "top", ) val cardH by animateDpAsState( targetHeight, animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp, label = "h", ) val alpha by animateFloatAsState( if (concealed) 0f else 1f, animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat, label = "a", ) val zIndex = if (isActive) 99f else (50 - distance).toFloat() val density = LocalDensity.current StackCard( spec = card, active = isActive, focused = isActive && !deckOpen, lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings), showSparkle = card.id == CardId.Pro && isPro, celebrating = celebrating == card.id, onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null, body = { CardBody( id = card.id, fg = card.fg, vm = vm, settings = settings, entriesCount = entries.count { it.deletedAt == null }, onNavigate = onNavigate, ) }, modifier = Modifier .fillMaxWidth() .align(Alignment.TopStart) .zIndex(zIndex) // Drag the deck like a physical stack: flick a tab up to focus // it. Only wired for non-active tabs — the active card's own // scrollable body handles its half of the gesture (expand from // preview, collapse from focus) via StackCard's nested scroll // connection below. Attaching this to the active card too used // to fight that connection for the same touch: this pointerInput // keys on `deckOpen`/`isActive`, which the nested-scroll actions // mutate mid-gesture, restarting this detector with the finger // still down and firing its own independent resolution — a // visible flicker as two systems retargeted the spring in quick // succession. .then( if (!isActive) { Modifier.pointerInput(card.id, deckOpen) { var dragY = 0f val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() } detectDragGestures( onDragStart = { dragY = 0f }, onDrag = { change, amount -> change.consume(); dragY += amount.y }, onDragEnd = { if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) { active = card.id deckOpen = false } }, ) } } else { Modifier }, ) .graphicsLayer { translationY = top.toPx() this.alpha = alpha } .height(cardH), onClick = { // Tapping the active card is a no-op (browser StackDeck // parity); flick it up to focus, a tab up to focus that one. if (!isActive) { active = card.id deckOpen = false } }, onCollapseRequest = { deckOpen = true }, onExpandRequest = { active = card.id; deckOpen = false }, enabled = !concealed, ) } } // Browser parity (.stk-version-corner): the build, faintly, in the // gutter the deck already leaves below itself. Worth having on the // phone specifically — sync is a lockstep wire contract, so "which // build is actually on this device" is the first question when // pairing misbehaves. Text( "v${BuildConfig.VERSION_NAME}", color = peachColors.T2.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp, textAlign = TextAlign.Center, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() // The screen's Column only pads the status bar, so without // this the label lands under the gesture pill. .navigationBarsPadding() .padding(bottom = PeachSpacing.xs), ) } } if (showCelebration) { ProCelebration(onDone = { vm.dismissCelebration(); celebrate() }) } } } } @Composable private fun StackCard( spec: StackCardSpec, active: Boolean, focused: Boolean, lede: String, showSparkle: Boolean, celebrating: Boolean, onIconClick: (() -> Unit)?, body: @Composable () -> Unit, modifier: Modifier, onClick: () -> Unit, onCollapseRequest: () -> Unit, onExpandRequest: () -> Unit, enabled: Boolean, ) { val interaction = remember { MutableInteractionSource() } Box( modifier .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false) .clip(RoundedCornerShape(PeachStackColors.CardRadius)) .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) } .clickable( interactionSource = interaction, indication = null, enabled = enabled && !active || (active && !focused), ) { onClick() } .padding( horizontal = if (active) 18.dp else 16.dp, vertical = if (active) 16.dp else 8.dp, ), ) { if (showSparkle) SparkleDust(Modifier.fillMaxSize()) if (active) { Column(Modifier.fillMaxSize()) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( spec.num, color = spec.fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 8.5.sp, letterSpacing = 0.26.em, ) Box(contentAlignment = Alignment.Center) { Box( Modifier.clip(RoundedCornerShape(999.dp)) .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier) .padding(2.dp), ) { CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f)) } // Not clipped like the icon above it — sparks fly past the // icon's own circular hit target (peach-pro-spark-fly, up // to 54dp out from centre). if (celebrating) ProSparkBurst(color = spec.fg) } } Text( spec.title, modifier = Modifier.padding(top = 6.dp), color = spec.fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, ) Text( lede, modifier = Modifier.padding(top = 4.dp), color = spec.fg.copy(alpha = 0.72f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 12.5.sp, ) if (celebrating) ProThankYouFlash(color = spec.fg) // Body scrolls independently; only revealed at full height in // focus mode, but kept mounted so deck-mode shows a preview. // Two-way scroll gesture, mirrored: dragging up on a collapsed- // to-preview card expands it straight away (a cramped preview // has nothing worth scrolling, so intercept in onPreScroll // before its own verticalScroll ever sees the drag) — down-only // is deliberately excluded so the same downward motion that // just collapsed a card can't immediately re-open it. Dragging // a focused card down past its own scroll top hands the // leftover delta to the deck to collapse, instead of letting // it disappear as a no-op overscroll. val scrollState = rememberScrollState() val density = LocalDensity.current val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() } // Read the latest focused/active through State rather than as // remember() keys, so the connection object (and its accumulators) // survives recompositions the gesture's own resolution triggers. val latestFocused = rememberUpdatedState(focused) val latestActive = rememberUpdatedState(active) val nestedScrollConnection = remember(scrollState) { object : NestedScrollConnection { var expandAccum = 0f var collapseAccum = 0f // Resolved once on release (onPreFling), not continuously // mid-drag — matching resolveStackDrag's on-release convention // above. Firing mid-drag flipped `focused` while the finger // was still delivering deltas from the same physical touch, // which the OTHER stack-tab pointerInput (keyed on `deckOpen`) // would also observe and restart on, independently resolving // the same gesture a second time — a visible flicker. override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (!latestActive.value || latestFocused.value || source != NestedScrollSource.UserInput || available.y == 0f) { return Offset.Zero } expandAccum += available.y return available } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { if (!latestFocused.value || source != NestedScrollSource.UserInput || scrollState.value != 0 || available.y <= 0f) { return Offset.Zero } collapseAccum += available.y return Offset(0f, available.y) } override suspend fun onPreFling(available: Velocity): Velocity { // Upward drags only (matches the tab-flick-up convention // used elsewhere in this deck). A downward drag repeated // right after a collapse — muscle memory from "scroll to // top to collapse" — would otherwise re-satisfy this on // the now-unfocused preview and flicker straight back open. if (expandAccum <= -gestureThresholdPx) { onExpandRequest() } else if (collapseAccum >= gestureThresholdPx) { onCollapseRequest() } expandAccum = 0f collapseAccum = 0f return Velocity.Zero } } } Column( Modifier.fillMaxWidth().weight(1f) .padding(top = 14.dp) .nestedScroll(nestedScrollConnection) .verticalScroll(scrollState), ) { body() // Trailing breathing room so bottom-of-card content (e.g. the // Pro card's CTA button) never sits flush against the fanned // tab stacked directly beneath it. Spacer(Modifier.height(PeachSpacing.xl)) } } } else { // Tab: just the title, single line, vertically centred. Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { Text( spec.title, color = spec.fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontWeight = FontWeight.Normal, fontSize = 19.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } @Composable private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) { PeachTopBar( title = "Settings", leading = { Box( Modifier .size(40.dp) .clip(RoundedCornerShape(999.dp)) .clickable(onClick = onBack), contentAlignment = Alignment.Center, ) { Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp) } }, actions = { Text( "${activeIndex + 1} / $total", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.22.em, modifier = Modifier.padding(horizontal = PeachSpacing.sm), ) }, ) } /** * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed * from the live draw size so the angle holds on any card height, unlike Compose's * default corner-to-corner linear gradient. */ private fun DrawScope.cardGradient155(start: Color, end: Color): Brush { val a = Math.toRadians(155.0) val dx = kotlin.math.sin(a).toFloat() val dy = -kotlin.math.cos(a).toFloat() val cx = size.width / 2f val cy = size.height / 2f val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f return Brush.linearGradient( colors = listOf(start, end), start = Offset(cx - dx * half, cy - dy * half), end = Offset(cx + dx * half, cy + dy * half), ) } private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String) // Exact positions/delays/glyphs from the browser's .peach-pro-spark spans // (Settings.tsx) — an 8-point radial burst, not a uniform ring. private val PRO_SPARKS = listOf( ProSpark(0f, -54f, 0, "✦"), ProSpark(38f, -38f, 50, "✦"), ProSpark(54f, 0f, 110, "·"), ProSpark(38f, 38f, 30, "✦"), ProSpark(0f, 54f, 80, "·"), ProSpark(-38f, 38f, 130, "✦"), ProSpark(-54f, 0f, 55, "·"), ProSpark(-38f, -38f, 95, "✦"), ) /** * The Pro icon's celebration burst — 8 glyphs flying outward from centre and * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out): * opacity 0→1 over the first quarter, then 1→0 through to the end, while * position/scale interpolate linearly (eased) across the full duration. */ @Composable private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) { if (LocalReduceMotion.current) return Box(modifier) { PRO_SPARKS.forEach { spark -> val progress = remember(spark) { Animatable(0f) } LaunchedEffect(spark) { delay(spark.delayMs.toLong()) progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing)) } val t = progress.value val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f val scale = 0.5f + 0.3f * t Text( spark.glyph, color = color.copy(alpha = alpha.coerceIn(0f, 1f)), fontSize = 9.sp, modifier = Modifier .align(Alignment.Center) .offset(x = (spark.x * t).dp, y = (spark.y * t).dp) .graphicsLayer { scaleX = scale; scaleY = scale }, ) } } } /** * "thank you ✦" beneath the Pro card's lede while celebrating — ports * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out * from 75%). */ @Composable private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) { val reduceMotion = LocalReduceMotion.current val alpha = remember { Animatable(0f) } LaunchedEffect(Unit) { if (reduceMotion) { alpha.snapTo(0.75f) } else { alpha.animateTo( 0f, keyframes { durationMillis = 2500 0f at 0 0.75f at 375 0.75f at 1875 0f at 2500 }, ) } } Text( "thank you ✦", modifier = modifier.padding(top = 4.dp), color = color.copy(alpha = alpha.value), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp, ) } /** * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly * translate diagonally and loop, screen-blended at low opacity — the Compose * analogue of the browser's `stk-pro-dust` keyframes. */ @Composable private fun SparkleDust(modifier: Modifier = Modifier) { val reduceMotion = LocalReduceMotion.current val t = rememberInfiniteTransition(label = "dust") val drift by if (reduceMotion) { remember { mutableStateOf(0f) } } else { t.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)), label = "drift", ) } Box( modifier.drawWithCache { val tile = 48.dp.toPx() val dx = drift * tile onDrawBehind { val cols = (size.width / tile).toInt() + 2 val rows = (size.height / tile).toInt() + 2 for (r in 0..rows) { for (c in 0..cols) { val baseX = c * tile + dx val baseY = r * tile + dx drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen) drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen) } } } }, ) }