OnboardingScreen.kt

13.3 KB · raw

1package com.peachpasswords.ui.onboarding23import androidx.compose.animation.core.Animatable4import androidx.compose.animation.core.animateFloat5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Row13import androidx.compose.foundation.layout.Spacer14import androidx.compose.foundation.layout.fillMaxSize15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.height17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.padding19import androidx.compose.foundation.layout.size20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.shape.RoundedCornerShape23import androidx.compose.runtime.Composable24import androidx.compose.runtime.LaunchedEffect25import androidx.compose.runtime.getValue26import androidx.compose.runtime.mutableIntStateOf27import androidx.compose.runtime.remember28import androidx.compose.runtime.setValue29import androidx.compose.ui.Alignment30import androidx.compose.ui.Modifier31import androidx.compose.ui.draw.alpha32import androidx.compose.ui.draw.clip33import androidx.compose.ui.draw.drawBehind34import androidx.compose.ui.geometry.Offset35import androidx.compose.ui.graphics.PathEffect36import androidx.compose.ui.platform.LocalContext37import androidx.compose.ui.platform.LocalDensity38import androidx.compose.ui.text.TextStyle39import androidx.compose.ui.text.font.FontStyle40import androidx.compose.ui.unit.IntOffset41import androidx.compose.ui.unit.dp42import androidx.compose.ui.unit.em43import androidx.compose.ui.unit.sp44import androidx.compose.material3.Text45import com.peachpasswords.ui.components.RuneGridBackground46import com.peachpasswords.ui.theme.LocalPeachAccent47import com.peachpasswords.ui.theme.peachColors48import com.peachpasswords.ui.theme.PeachType49import com.peachpasswords.ui.theme.ShellBackground5051/**52 * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a53 * single source of truth (and testable). Ported from the browser onboarding54 * (`popup/views/Onboarding.tsx`); the warning sits on the final card per the55 * mobile layout, which trades the browser's prose card 3 for feature-style copy.56 */57data class OnboardingCardCopy(58    val headline: String,59    val body: List<String>,60    val warning: String?,61)6263/** The three onboarding cards, in order. */64val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(65    OnboardingCardCopy(66        headline = "your passwords\nlive here.\nnowhere else.",67        body = listOf(68            "No account. No server. No one but you can ever see your vault.",69            "Everything is encrypted on your device with a password only you know.",70        ),71        warning = null,72    ),73    OnboardingCardCopy(74        headline = "two keys.",75        body = listOf(76            "Your master password is yours alone. Peach never sees it.",77            "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",78        ),79        warning = null,80    ),81    OnboardingCardCopy(82        headline = "everything\nyou need.",83        body = listOf(84            "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",85            "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",86        ),87        warning = "Lose both your password and your recovery words and your vault cannot be opened.",88    ),89)9091/**92 * First-run onboarding: a three-card swipeable intro shown once before the93 * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits94 * behind the glass cards as ambient background. The "onboarded" flag lives in95 * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard96 * that decides whether to route here ([onboardingStartDestination]) is applied in97 * MainActivity — this screen only writes the flag on completion.98 */99@Composable100fun OnboardingScreen(101    onFinished: () -> Unit,102) {103    val context = LocalContext.current104    var card by remember { mutableIntStateOf(0) }105    val cardCount = ONBOARDING_CARDS.size106    val accent = LocalPeachAccent.current107108    val finish: () -> Unit = {109        context110            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)111            .edit()112            .putBoolean(ONBOARD_KEY, true)113            .apply()114        onFinished()115    }116117    ShellBackground {118        RuneGridBackground(modifier = Modifier.fillMaxSize())119120        // Wordmark, top-left — matches LockScreen.121        Text(122            text = "peach",123            color = accent,124            style = PeachType.wordmark,125            modifier = Modifier126                .align(Alignment.TopStart)127                .systemBarsPadding()128                .padding(start = 20.dp, top = 8.dp),129        )130131        Column(132            modifier = Modifier133                .fillMaxSize()134                .systemBarsPadding()135                .padding(horizontal = 28.dp),136            verticalArrangement = Arrangement.Center,137            horizontalAlignment = Alignment.CenterHorizontally,138        ) {139            CardSwitcher(card) { active ->140                GlassCardPanel {141                    CardContent(ONBOARDING_CARDS[active])142                }143            }144145            Spacer(Modifier.height(28.dp))146147            NavRow(148                card = card,149                cardCount = cardCount,150                onBack = { if (card > 0) card-- },151                onNext = { if (card < cardCount - 1) card++ else finish() },152            )153        }154    }155}156157// ── Card switcher (enter/exit transition) ───────────────────────────────────158159/**160 * Crossfade-style switcher that runs the spec's directional transition:161 *   exit  translateX(0 → -12.dp) + alpha(1 → 0) over 120ms ease-in162 *   enter translateX(12.dp → 0)  + alpha(0 → 1) over 200ms spring163 *164 * Implemented as a two-phase animation keyed on [target]: the old card slides165 * left and fades, then the new card slides in from the right.166 */167@Composable168private fun CardSwitcher(169    target: Int,170    content: @Composable (Int) -> Unit,171) {172    val density = LocalDensity.current173    var shown by remember { mutableIntStateOf(target) }174    val translate = remember { Animatable(0f) }175    val fade = remember { Animatable(1f) }176177    LaunchedEffect(target) {178        if (target == shown) return@LaunchedEffect179        // Exit current card.180        val exitPx = with(density) { (-12).dp.toPx() }181        translate.animateTo(exitPx, tween(120))182        fade.animateTo(0f, tween(120))183        // Swap, then enter from the right.184        shown = target185        translate.snapTo(with(density) { 12.dp.toPx() })186        // Spring per spec; PeachMotion.Spring is the app's shared spring easing.187        translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring))188    }189    LaunchedEffect(shown) {190        if (fade.value < 1f) fade.animateTo(1f, tween(200))191    }192193    Box(194        modifier = Modifier195            .fillMaxWidth()196            .offset { IntOffset(translate.value.toInt(), 0) }197            .alpha(fade.value),198    ) {199        content(shown)200    }201}202203// ── Glass card panel ────────────────────────────────────────────────────────204205@Composable206private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {207    Column(208        modifier = Modifier209            .fillMaxWidth()210            .clip(RoundedCornerShape(18.dp))211            .background(peachColors.SurfaceElevated)212            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))213            .padding(horizontal = 24.dp, vertical = 28.dp),214        content = content,215    )216}217218// ── Card content ─────────────────────────────────────────────────────────────219220private fun headline() = TextStyle(221    fontFamily = PeachType.Display,222    fontStyle = FontStyle.Italic,223    fontSize = 40.sp,224    lineHeight = (40 * 0.98).sp,225)226227@Composable228private fun CardContent(copy: OnboardingCardCopy) {229    Text(copy.headline, color = peachColors.T0, style = headline())230    Spacer(Modifier.height(18.dp))231    copy.body.forEachIndexed { i, para ->232        if (i > 0) Spacer(Modifier.height(12.dp))233        Text(234            para,235            color = peachColors.T1,236            style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),237        )238    }239    copy.warning?.let { WarningRow(it) }240}241242@Composable243private fun WarningRow(text: String) {244    val accent = LocalPeachAccent.current245    Spacer(Modifier.height(20.dp))246    Column(247        modifier = Modifier248            .fillMaxWidth()249            .drawBehind {250                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)251                drawLine(252                    color = accent,253                    start = Offset(0f, 0f),254                    end = Offset(size.width, 0f),255                    strokeWidth = 1.dp.toPx(),256                    pathEffect = dash,257                )258            }259            .padding(top = 12.dp),260    ) {261        Row(verticalAlignment = Alignment.Top) {262            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))263            Spacer(Modifier.width(8.dp))264            Text(265                text,266                color = peachColors.T1,267                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),268            )269        }270    }271}272273// ── Nav row ─────────────────────────────────────────────────────────────────274275/** Back is hidden (space reserved) on the first card; visible from card 2 on. */276internal fun onboardingBackVisible(card: Int): Boolean = card > 0277278/** The forward button reads "Get started" on the final card, else "next". */279internal fun onboardingNextLabel(card: Int, count: Int): String =280    if (card == count - 1) "Get started" else "next"281282@Composable283private fun NavRow(284    card: Int,285    cardCount: Int,286    onBack: () -> Unit,287    onNext: () -> Unit,288) {289    val accent = LocalPeachAccent.current290    Row(291        modifier = Modifier.fillMaxWidth(),292        verticalAlignment = Alignment.CenterVertically,293    ) {294        // Back — hidden (but space reserved) on card 1.295        Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.CenterStart) {296            if (onboardingBackVisible(card)) {297                Text(298                    "back",299                    color = peachColors.T2,300                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),301                    modifier = Modifier302                        .clip(RoundedCornerShape(6.dp))303                        .clickable(onClick = onBack)304                        .padding(vertical = 6.dp, horizontal = 4.dp),305                )306            }307        }308309        // Dot indicators, centered.310        Row(311            modifier = Modifier.weight(1f),312            horizontalArrangement = Arrangement.Center,313            verticalAlignment = Alignment.CenterVertically,314        ) {315            repeat(cardCount) { i ->316                val activeDot = i == card317                Box(318                    modifier = Modifier319                        .padding(horizontal = 4.dp)320                        .size(if (activeDot) 8.dp else 6.dp)321                        .clip(RoundedCornerShape(999.dp))322                        .background(if (activeDot) accent else peachColors.T2),323                )324            }325        }326327        // Next / Get started.328        Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {329            Text(330                onboardingNextLabel(card, cardCount),331                color = peachColors.T1,332                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),333                modifier = Modifier334                    .clip(RoundedCornerShape(6.dp))335                    .clickable(onClick = onNext)336                    .padding(vertical = 6.dp, horizontal = 4.dp),337            )338        }339    }340}341342// ── SharedPreferences contract (mirrored by the launch guard) ────────────────343344const val ONBOARD_PREFS = "peach_prefs"345const val ONBOARD_KEY = "peach_onboarded"346347/**348 * Where the app starts: onboarding runs once, only on a fresh install that has no349 * vault yet and has not completed onboarding. Otherwise go straight to the lock350 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",351 * so they land on Lock, not back through the intro.)352 */353fun onboardingStartDestination(354    hasVault: Boolean,355    onboarded: Boolean,356): com.peachpasswords.ui.PeachNav =357    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding358    else com.peachpasswords.ui.PeachNav.Lock