OnboardingScreen.kt

16.0 KB · raw

1package com.peachpasswords.ui.onboarding23import androidx.compose.animation.core.Animatable4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.animateFloat6import androidx.compose.animation.core.infiniteRepeatable7import androidx.compose.animation.core.rememberInfiniteTransition8import androidx.compose.animation.core.tween9import androidx.compose.foundation.Canvas10import androidx.compose.foundation.background11import androidx.compose.foundation.border12import androidx.compose.foundation.clickable13import androidx.compose.foundation.layout.Arrangement14import androidx.compose.foundation.layout.Box15import androidx.compose.foundation.layout.Column16import androidx.compose.foundation.layout.Row17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxSize19import androidx.compose.foundation.layout.fillMaxWidth20import androidx.compose.foundation.layout.height21import androidx.compose.foundation.layout.offset22import androidx.compose.foundation.layout.padding23import androidx.compose.foundation.layout.size24import androidx.compose.foundation.layout.systemBarsPadding25import androidx.compose.foundation.layout.width26import androidx.compose.foundation.shape.RoundedCornerShape27import androidx.compose.runtime.Composable28import androidx.compose.runtime.LaunchedEffect29import androidx.compose.runtime.getValue30import androidx.compose.runtime.mutableIntStateOf31import androidx.compose.runtime.remember32import androidx.compose.runtime.setValue33import androidx.compose.ui.Alignment34import androidx.compose.ui.Modifier35import androidx.compose.ui.draw.alpha36import androidx.compose.ui.draw.clip37import androidx.compose.ui.draw.drawBehind38import androidx.compose.ui.geometry.Offset39import androidx.compose.ui.graphics.Color40import androidx.compose.ui.graphics.PathEffect41import androidx.compose.ui.platform.LocalContext42import androidx.compose.ui.platform.LocalDensity43import androidx.compose.ui.text.TextStyle44import androidx.compose.ui.text.drawText45import androidx.compose.ui.text.font.FontStyle46import androidx.compose.ui.text.rememberTextMeasurer47import androidx.compose.ui.unit.IntOffset48import androidx.compose.ui.unit.dp49import androidx.compose.ui.unit.em50import androidx.compose.ui.unit.sp51import androidx.compose.material3.Text52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.LocalReduceMotion54import com.peachpasswords.ui.theme.peachColors55import com.peachpasswords.ui.theme.PeachType56import com.peachpasswords.ui.theme.ShellBackground57import kotlin.random.Random5859/**60 * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a61 * single source of truth (and testable). Ported from the browser onboarding62 * (`popup/views/Onboarding.tsx`); the warning sits on the final card per the63 * mobile layout, which trades the browser's prose card 3 for feature-style copy.64 */65data class OnboardingCardCopy(66    val headline: String,67    val body: List<String>,68    val warning: String?,69)7071/** The three onboarding cards, in order. */72val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(73    OnboardingCardCopy(74        headline = "your passwords\nlive here.\nnowhere else.",75        body = listOf(76            "No account. No server. No one but you can ever see your vault.",77            "Everything is encrypted on your device with a password only you know.",78        ),79        warning = null,80    ),81    OnboardingCardCopy(82        headline = "two keys.",83        body = listOf(84            "Your master password is yours alone. Peach never sees it.",85            "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",86        ),87        warning = null,88    ),89    OnboardingCardCopy(90        headline = "everything\nyou need.",91        body = listOf(92            "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",93            "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",94        ),95        warning = "Lose both your password and your recovery words and your vault cannot be opened.",96    ),97)9899/**100 * First-run onboarding: a three-card swipeable intro shown once before the101 * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits102 * behind the glass cards as ambient background. The "onboarded" flag lives in103 * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard104 * that decides whether to route here ([onboardingStartDestination]) is applied in105 * MainActivity — this screen only writes the flag on completion.106 */107@Composable108fun OnboardingScreen(109    onFinished: () -> Unit,110) {111    val context = LocalContext.current112    var card by remember { mutableIntStateOf(0) }113    val cardCount = ONBOARDING_CARDS.size114    val accent = LocalPeachAccent.current115116    val finish: () -> Unit = {117        context118            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)119            .edit()120            .putBoolean(ONBOARD_KEY, true)121            .apply()122        onFinished()123    }124125    ShellBackground {126        // Ambient PeachScript rain behind everything.127        PeachScriptRainCanvas(modifier = Modifier.fillMaxSize(), accent = accent)128129        // Wordmark, top-left — matches LockScreen.130        Text(131            text = "peach",132            color = accent,133            style = PeachType.wordmark,134            modifier = Modifier135                .align(Alignment.TopStart)136                .systemBarsPadding()137                .padding(start = 20.dp, top = 8.dp),138        )139140        Column(141            modifier = Modifier142                .fillMaxSize()143                .systemBarsPadding()144                .padding(horizontal = 28.dp),145            verticalArrangement = Arrangement.Center,146            horizontalAlignment = Alignment.CenterHorizontally,147        ) {148            CardSwitcher(card) { active ->149                GlassCardPanel {150                    CardContent(ONBOARDING_CARDS[active])151                }152            }153154            Spacer(Modifier.height(28.dp))155156            NavRow(157                card = card,158                cardCount = cardCount,159                onBack = { if (card > 0) card-- },160                onNext = { if (card < cardCount - 1) card++ else finish() },161            )162        }163    }164}165166// ── PeachScript rain ─────────────────────────────────────────────────────────167168private const val RAIN_COLUMNS = 26169170private class RainColumn(171    val glyph: String,172    val xFraction: Float,173    val speed: Float,174    val startOffset: Float,175)176177/**178 * A field of ~26 PeachScript glyphs drifting very slowly downward — ambient, not179 * Matrix: each column has its own x, speed (≈40–80s per cycle), and start phase,180 * drawn in the accent at ~9% opacity. Honors reduce-motion by holding every glyph181 * at its start position (still rendered, just not animated).182 */183@Composable184fun PeachScriptRainCanvas(modifier: Modifier = Modifier, accent: Color) {185    val reduceMotion = LocalReduceMotion.current186    val measurer = rememberTextMeasurer()187    val columns = remember {188        List(RAIN_COLUMNS) {189            RainColumn(190                glyph = (0xE000 + Random.nextInt(256)).toChar().toString(),191                xFraction = Random.nextFloat(),192                // 0.5–1.0 → with the 80s base cycle, ≈40–80s per full fall.193                speed = 0.5f + Random.nextFloat() * 0.5f,194                startOffset = Random.nextFloat(),195            )196        }197    }198    val glyphStyle = remember(accent) {199        TextStyle(fontFamily = PeachType.Script, fontSize = 30.sp, color = accent.copy(alpha = 0.09f))200    }201    val measured = remember(columns, glyphStyle, measurer) {202        columns.map { measurer.measure(it.glyph, glyphStyle) }203    }204205    val phase: Float = if (reduceMotion) {206        0f207    } else {208        val transition = rememberInfiniteTransition(label = "peachscript_rain")209        val animated by transition.animateFloat(210            initialValue = 0f,211            targetValue = 1f,212            animationSpec = infiniteRepeatable(tween(80_000, easing = LinearEasing)),213            label = "rain_phase",214        )215        animated216    }217218    Canvas(modifier) {219        val glyphPx = 30.sp.toPx()220        val travel = size.height + glyphPx * 2221        columns.forEachIndexed { i, col ->222            val x = col.xFraction * size.width223            val progress = ((phase * col.speed) + col.startOffset) % 1f224            val y = progress * travel - glyphPx225            drawText(measured[i], topLeft = Offset(x, y))226        }227    }228}229230// ── Card switcher (enter/exit transition) ───────────────────────────────────231232/**233 * Crossfade-style switcher that runs the spec's directional transition:234 *   exit  translateX(0 → -12.dp) + alpha(1 → 0) over 120ms ease-in235 *   enter translateX(12.dp → 0)  + alpha(0 → 1) over 200ms spring236 *237 * Implemented as a two-phase animation keyed on [target]: the old card slides238 * left and fades, then the new card slides in from the right.239 */240@Composable241private fun CardSwitcher(242    target: Int,243    content: @Composable (Int) -> Unit,244) {245    val density = LocalDensity.current246    var shown by remember { mutableIntStateOf(target) }247    val translate = remember { Animatable(0f) }248    val fade = remember { Animatable(1f) }249250    LaunchedEffect(target) {251        if (target == shown) return@LaunchedEffect252        // Exit current card.253        val exitPx = with(density) { (-12).dp.toPx() }254        translate.animateTo(exitPx, tween(120))255        fade.animateTo(0f, tween(120))256        // Swap, then enter from the right.257        shown = target258        translate.snapTo(with(density) { 12.dp.toPx() })259        // Spring per spec; PeachMotion.Spring is the app's shared spring easing.260        translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring))261    }262    LaunchedEffect(shown) {263        if (fade.value < 1f) fade.animateTo(1f, tween(200))264    }265266    Box(267        modifier = Modifier268            .fillMaxWidth()269            .offset { IntOffset(translate.value.toInt(), 0) }270            .alpha(fade.value),271    ) {272        content(shown)273    }274}275276// ── Glass card panel ────────────────────────────────────────────────────────277278@Composable279private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {280    Column(281        modifier = Modifier282            .fillMaxWidth()283            .clip(RoundedCornerShape(18.dp))284            .background(peachColors.SurfaceElevated)285            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))286            .padding(horizontal = 24.dp, vertical = 28.dp),287        content = content,288    )289}290291// ── Card content ─────────────────────────────────────────────────────────────292293private fun headline() = TextStyle(294    fontFamily = PeachType.Display,295    fontStyle = FontStyle.Italic,296    fontSize = 40.sp,297    lineHeight = (40 * 0.98).sp,298)299300@Composable301private fun CardContent(copy: OnboardingCardCopy) {302    Text(copy.headline, color = peachColors.T0, style = headline())303    Spacer(Modifier.height(18.dp))304    copy.body.forEachIndexed { i, para ->305        if (i > 0) Spacer(Modifier.height(12.dp))306        Text(307            para,308            color = peachColors.T1,309            style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),310        )311    }312    copy.warning?.let { WarningRow(it) }313}314315@Composable316private fun WarningRow(text: String) {317    val accent = LocalPeachAccent.current318    Spacer(Modifier.height(20.dp))319    Column(320        modifier = Modifier321            .fillMaxWidth()322            .drawBehind {323                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)324                drawLine(325                    color = accent,326                    start = Offset(0f, 0f),327                    end = Offset(size.width, 0f),328                    strokeWidth = 1.dp.toPx(),329                    pathEffect = dash,330                )331            }332            .padding(top = 12.dp),333    ) {334        Row(verticalAlignment = Alignment.Top) {335            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))336            Spacer(Modifier.width(8.dp))337            Text(338                text,339                color = peachColors.T1,340                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),341            )342        }343    }344}345346// ── Nav row ─────────────────────────────────────────────────────────────────347348/** Back is hidden (space reserved) on the first card; visible from card 2 on. */349internal fun onboardingBackVisible(card: Int): Boolean = card > 0350351/** The forward button reads "Get started" on the final card, else "next". */352internal fun onboardingNextLabel(card: Int, count: Int): String =353    if (card == count - 1) "Get started" else "next"354355@Composable356private fun NavRow(357    card: Int,358    cardCount: Int,359    onBack: () -> Unit,360    onNext: () -> Unit,361) {362    val accent = LocalPeachAccent.current363    Row(364        modifier = Modifier.fillMaxWidth(),365        verticalAlignment = Alignment.CenterVertically,366    ) {367        // Back — hidden (but space reserved) on card 1.368        Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.CenterStart) {369            if (onboardingBackVisible(card)) {370                Text(371                    "back",372                    color = peachColors.T2,373                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),374                    modifier = Modifier375                        .clip(RoundedCornerShape(6.dp))376                        .clickable(onClick = onBack)377                        .padding(vertical = 6.dp, horizontal = 4.dp),378                )379            }380        }381382        // Dot indicators, centered.383        Row(384            modifier = Modifier.weight(1f),385            horizontalArrangement = Arrangement.Center,386            verticalAlignment = Alignment.CenterVertically,387        ) {388            repeat(cardCount) { i ->389                val activeDot = i == card390                Box(391                    modifier = Modifier392                        .padding(horizontal = 4.dp)393                        .size(if (activeDot) 8.dp else 6.dp)394                        .clip(RoundedCornerShape(999.dp))395                        .background(if (activeDot) accent else peachColors.T2),396                )397            }398        }399400        // Next / Get started.401        Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {402            Text(403                onboardingNextLabel(card, cardCount),404                color = peachColors.T1,405                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),406                modifier = Modifier407                    .clip(RoundedCornerShape(6.dp))408                    .clickable(onClick = onNext)409                    .padding(vertical = 6.dp, horizontal = 4.dp),410            )411        }412    }413}414415// ── SharedPreferences contract (mirrored by the launch guard) ────────────────416417const val ONBOARD_PREFS = "peach_prefs"418const val ONBOARD_KEY = "peach_onboarded"419420/**421 * Where the app starts: onboarding runs once, only on a fresh install that has no422 * vault yet and has not completed onboarding. Otherwise go straight to the lock423 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",424 * so they land on Lock, not back through the intro.)425 */426fun onboardingStartDestination(427    hasVault: Boolean,428    onboarded: Boolean,429): com.peachpasswords.ui.PeachNav =430    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding431    else com.peachpasswords.ui.PeachNav.Lock