package com.peachpasswords.ui.vault import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.peachpasswords.ui.components.PeachMark import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.LocalReduceMotion import com.peachpasswords.ui.theme.PeachType import kotlin.math.PI import kotlin.math.min import kotlin.math.sin import kotlin.random.Random /** Which leg of the restore animation is running (browser: RestoreSymbols' `animating`). */ enum class RunePhase { CHAOS, SNAP, BLOOM } private const val RUNE_COUNT = 60 private const val GRID_COLS = 8 private const val GRID_ROWS = 6 private const val SNAP_MS = 400f private const val BLOOM_MS = 900f private class Rune( var x: Float, var y: Float, val vx: Float, val vy: Float, val glyph: String, val size: Float, var angle: Float, val va: Float, ) /** * The PeachScript restore's chaos → snap → bloom sequence, ported from the * browser's RestoreSymbols (popup/views/Lock.tsx): loose runes drift while the * backup decrypts, snap into a grid once it lands, then dissolve as the peach * mark blooms through. [phase] is driven by the import state; [onFinished] * fires when BLOOM completes so the caller can reveal its result screen. */ @Composable fun RuneResolve( phase: RunePhase, modifier: Modifier = Modifier, onFinished: () -> Unit = {}, ) { val accent = LocalPeachAccent.current val reduceMotion = LocalReduceMotion.current val measurer = rememberTextMeasurer() // Fixed seed: the texture should look the same every restore, like the // browser's deterministic rune string. val runes = remember { val rng = Random(7) List(RUNE_COUNT) { Rune( x = rng.nextFloat(), y = rng.nextFloat(), vx = (rng.nextFloat() - 0.5f) * 3f, vy = (rng.nextFloat() - 0.5f) * 3f, glyph = (0xE000 + rng.nextInt(40)).toChar().toString(), size = 14f + rng.nextFloat() * 20f, angle = rng.nextFloat() * 2f * PI.toFloat(), va = (rng.nextFloat() - 0.5f) * 0.1f, ) } } // Normalised x/y are seeded once per size; kept in px after first layout. var seeded by remember { mutableFloatStateOf(0f) } var elapsed by remember(phase) { mutableFloatStateOf(0f) } var clock by remember { mutableFloatStateOf(0f) } LaunchedEffect(phase) { if (reduceMotion) { onFinished() return@LaunchedEffect } var last = withFrameMillis { it } while (true) { val now = withFrameMillis { it } val dt = (now - last).toFloat().coerceAtMost(64f) last = now clock += dt elapsed += dt val done = when (phase) { RunePhase.CHAOS -> false RunePhase.SNAP -> elapsed >= SNAP_MS RunePhase.BLOOM -> elapsed >= BLOOM_MS } if (done) break } // CHAOS runs until the import state moves it on; the timed phases report // completion so the caller can advance (SNAP → BLOOM → result). onFinished() } // Peach mark blooms in over the second fifth of BLOOM, then fades back out. val markT = if (phase == RunePhase.BLOOM) ((elapsed / BLOOM_MS) - 0.2f) / 0.5f else 0f val markAlpha = when { markT <= 0f -> 0f markT < 0.5f -> markT * 2f else -> (2f - markT * 2f).coerceAtLeast(0f) } Box(modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) { Canvas(Modifier.fillMaxWidth().height(180.dp)) { if (seeded == 0f) { runes.forEach { it.x *= size.width; it.y *= size.height } seeded = 1f } val progress = (elapsed / SNAP_MS).coerceIn(0f, 1f) val ease = 1f - (1f - progress) * (1f - progress) * (1f - progress) val cellW = size.width / GRID_COLS val cellH = size.height / GRID_ROWS runes.forEachIndexed { i, r -> when (phase) { RunePhase.CHAOS -> { r.x = (r.x + r.vx + size.width) % size.width r.y = (r.y + r.vy + size.height) % size.height r.angle += r.va val a = 0.5f + 0.5f * sin(clock / 400f + r.x) drawGlyph(measurer, r.glyph, r.size, accent.copy(alpha = a), r.x, r.y, r.angle) } RunePhase.SNAP -> { if (i >= GRID_COLS * GRID_ROWS) return@forEachIndexed val tx = (i % GRID_COLS) * cellW + cellW / 2f val ty = (i / GRID_COLS) * cellH + cellH / 2f val cx = r.x + (tx - r.x) * ease val cy = r.y + (ty - r.y) * ease drawGlyph(measurer, r.glyph, 16f, accent.copy(alpha = 0.8f * ease), cx, cy, 0f) if (progress >= 1f) { r.x = tx; r.y = ty } } RunePhase.BLOOM -> { val a = 1f - min((elapsed / BLOOM_MS) / 0.4f, 1f) if (a > 0f && i < 48) { drawGlyph(measurer, r.glyph, 16f, accent.copy(alpha = a * 0.8f), r.x, r.y, 0f) } } } } } if (markAlpha > 0f) { PeachMark( size = 64.dp, modifier = Modifier.graphicsLayer { val s = min(markT * 1.2f, 1f) scaleX = s; scaleY = s; alpha = markAlpha }, ) } } } private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGlyph( measurer: androidx.compose.ui.text.TextMeasurer, glyph: String, sizeSp: Float, color: Color, x: Float, y: Float, angle: Float, ) { val laid = measurer.measure(glyph, TextStyle(fontFamily = PeachType.Script, fontSize = sizeSp.sp, color = color)) rotate(degrees = angle * 180f / PI.toFloat(), pivot = Offset(x, y)) { drawText(laid, topLeft = Offset(x - laid.size.width / 2f, y - laid.size.height / 2f)) } }