OnboardingScreen.kt

17.0 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.interaction.MutableInteractionSource10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxHeight16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.offset20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.systemBarsPadding23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.layout.widthIn25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.runtime.Composable27import androidx.compose.runtime.LaunchedEffect28import androidx.compose.runtime.getValue29import androidx.compose.runtime.mutableIntStateOf30import androidx.compose.runtime.remember31import androidx.compose.runtime.saveable.rememberSaveable32import 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.PathEffect40import androidx.compose.ui.platform.LocalContext41import androidx.compose.ui.platform.LocalDensity42import androidx.compose.ui.text.TextStyle43import androidx.compose.ui.text.font.FontStyle44import androidx.compose.ui.unit.IntOffset45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.em47import androidx.compose.ui.unit.sp48import androidx.compose.material3.Text49import com.peachpasswords.ui.components.RuneGridBackground50import com.peachpasswords.ui.theme.LocalPeachAccent51import com.peachpasswords.ui.theme.peachColors52import com.peachpasswords.ui.theme.PeachType53import com.peachpasswords.ui.theme.ShellBackground54import kotlinx.coroutines.coroutineScope55import kotlinx.coroutines.delay56import kotlinx.coroutines.launch5758/**59 * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a60 * single source of truth (and testable). Ported from the browser onboarding61 * (`popup/views/Onboarding.tsx`); the warning sits on the final card per the62 * mobile layout, which trades the browser's prose card 3 for feature-style copy.63 */64data class OnboardingCardCopy(65    val headline: String,66    val body: List<String>,67    val warning: String?,68)6970/**71 * The three onboarding cards, in order. Ported verbatim from the browser's72 * `BASE_CARDS` (popup/views/Onboarding.tsx) — the warning belongs to card 273 * ("two keys.", the master-password/recovery-words card), NOT card 3, which74 * carries no warning at all (ONBOARDING_ADDENDUM.md §1 — a prior pass had75 * this on the wrong card).76 */77val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(78    OnboardingCardCopy(79        headline = "your passwords\nlive here.\nnowhere else.",80        body = listOf(81            "No account. No server. No one but you can ever see your vault.",82            "Everything is encrypted on your device with a password only you know.",83        ),84        warning = null,85    ),86    OnboardingCardCopy(87        headline = "two keys.",88        body = listOf(89            "Your master password is yours alone. Peach never sees it.",90            "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",91        ),92        warning = "Lose both and your vault cannot be opened.",93    ),94    OnboardingCardCopy(95        headline = "everything\nyou need.",96        body = listOf(97            "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",98            "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",99        ),100        warning = null,101    ),102)103104/**105 * First-run onboarding: a three-card swipeable intro shown once before the106 * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits107 * behind the glass cards as ambient background. The "onboarded" flag lives in108 * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard109 * that decides whether to route here ([onboardingStartDestination]) is applied in110 * MainActivity — this screen only writes the flag on completion.111 */112@Composable113fun OnboardingScreen(114    onFinished: () -> Unit,115    // Card 3's "restore from backup" choice (Onboarding.tsx's onRestore prop).116    // "I already have Peach" (onAdopt) isn't wired yet — AdoptVaultView's real117    // semantics aren't confirmed on the Android side, so it isn't shown rather118    // than shipping a button that does the wrong thing or nothing at all.119    onRestore: () -> Unit = {},120) {121    val context = LocalContext.current122    // rememberSaveable, not plain remember — navigating to "restore from123    // backup" (a real pushed route, unlike the browser's in-place view swap)124    // and back must not reset progress to card 1.125    var card by rememberSaveable { mutableIntStateOf(0) }126    val cardCount = ONBOARDING_CARDS.size127    val accent = LocalPeachAccent.current128129    val finish: () -> Unit = {130        context131            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)132            .edit()133            .putBoolean(ONBOARD_KEY, true)134            .apply()135        onFinished()136    }137    val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }138139    ShellBackground {140        RuneGridBackground(modifier = Modifier.fillMaxSize())141142        // Wordmark, top-left — matches LockScreen.143        Text(144            text = "peach",145            color = accent,146            style = PeachType.wordmark,147            modifier = Modifier148                .align(Alignment.TopStart)149                .systemBarsPadding()150                .padding(start = 20.dp, top = 8.dp),151        )152153        Column(154            modifier = Modifier155                // Gate 1 (wide-screen/foldable audit): matches LockScreen's fix156                // — cap the card-style content instead of letting it stretch157                // edge-to-edge on tablet/foldable widths.158                .align(Alignment.Center)159                .fillMaxHeight()160                .widthIn(max = 480.dp)161                .fillMaxWidth()162                .systemBarsPadding()163                .padding(horizontal = 28.dp),164            verticalArrangement = Arrangement.Center,165            horizontalAlignment = Alignment.CenterHorizontally,166        ) {167            CardSwitcher(card, onClick = advance) { active ->168                GlassCardPanel {169                    CardContent(ONBOARDING_CARDS[active])170                    // The restore/adopt choice row only appears on the final171                    // card (Onboarding.tsx's `card() === CARDS.length - 1`).172                    if (active == cardCount - 1) {173                        ChoiceRow(onRestore = onRestore)174                    }175                }176            }177178            Spacer(Modifier.height(28.dp))179180            NavRow(181                card = card,182                cardCount = cardCount,183                onBack = { if (card > 0) card-- },184                onNext = advance,185            )186        }187    }188}189190// ── Card switcher (enter/exit transition) ───────────────────────────────────191192// CSS cubic-bezier(0.16, 1, 0.3, 1) — peach-card-in's easing (pronounced193// ease-out-expo). ease-in (translate+fade exit, peach-card-out) is CSS's194// standard cubic-bezier(0.42, 0, 1, 1).195private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)196private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)197198/**199 * Runs Onboarding.tsx's exact directional transition: the outgoing card slides200 * to translateX(-12dp) fading out over CARD_EXIT_MS=120ms (ease-in, both201 * properties together — not sequential), holds invisible for202 * CARD_ENTER_GAP_MS=60ms, then the new card content swaps in and slides from203 * translateX(12dp) to 0 fading in over 200ms (ease-out-expo).204 */205@Composable206private fun CardSwitcher(207    target: Int,208    onClick: () -> Unit,209    content: @Composable (Int) -> Unit,210) {211    val density = LocalDensity.current212    var shown by remember { mutableIntStateOf(target) }213    val translate = remember { Animatable(0f) }214    val fade = remember { Animatable(1f) }215216    LaunchedEffect(target) {217        if (target == shown) return@LaunchedEffect218        val exitPx = with(density) { (-12).dp.toPx() }219        coroutineScope {220            launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }221            launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }222        }223        delay(60)224        shown = target225        translate.snapTo(with(density) { 12.dp.toPx() })226        coroutineScope {227            launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }228            launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }229        }230    }231232    Box(233        modifier = Modifier234            .fillMaxWidth()235            .offset { IntOffset(translate.value.toInt(), 0) }236            .alpha(fade.value)237            // The whole card is a single tap target that advances, matching238            // Onboarding.tsx's card-level onClick (plus Enter/Space there).239            .clickable(240                interactionSource = remember { MutableInteractionSource() },241                indication = null,242                onClick = onClick,243            ),244    ) {245        content(shown)246    }247}248249// ── Glass card panel ────────────────────────────────────────────────────────250251@Composable252private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {253    Column(254        modifier = Modifier255            .fillMaxWidth()256            .clip(RoundedCornerShape(18.dp))257            .background(peachColors.SurfaceElevated)258            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))259            .padding(horizontal = 24.dp, vertical = 28.dp),260        content = content,261    )262}263264// ── Card content ─────────────────────────────────────────────────────────────265266private fun headline() = TextStyle(267    fontFamily = PeachType.Display,268    fontStyle = FontStyle.Italic,269    fontSize = 40.sp,270    lineHeight = (40 * 0.98).sp,271)272273@Composable274private fun CardContent(copy: OnboardingCardCopy) {275    Text(copy.headline, color = peachColors.T0, style = headline())276    Spacer(Modifier.height(18.dp))277    copy.body.forEachIndexed { i, para ->278        if (i > 0) Spacer(Modifier.height(12.dp))279        Text(280            para,281            color = peachColors.T1,282            style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),283        )284    }285    copy.warning?.let { WarningRow(it) }286}287288@Composable289private fun WarningRow(text: String) {290    val accent = LocalPeachAccent.current291    Spacer(Modifier.height(20.dp))292    Column(293        modifier = Modifier294            .fillMaxWidth()295            .drawBehind {296                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)297                drawLine(298                    color = accent,299                    start = Offset(0f, 0f),300                    end = Offset(size.width, 0f),301                    strokeWidth = 1.dp.toPx(),302                    pathEffect = dash,303                )304            }305            .padding(top = 12.dp),306    ) {307        Row(verticalAlignment = Alignment.Top) {308            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))309            Spacer(Modifier.width(8.dp))310            Text(311                text,312                color = peachColors.T1,313                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),314            )315        }316    }317}318319/**320 * Card 3's choice row (Onboarding.tsx `peach-onboarding-choice-row`). The321 * browser also has an "I already have Peach" button here (onAdopt) — not322 * shown yet, see [OnboardingScreen]'s onRestore doc.323 */324@Composable325private fun ChoiceRow(onRestore: () -> Unit) {326    Spacer(Modifier.height(14.dp))327    Text(328        "restore from backup",329        color = peachColors.T0,330        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),331        modifier = Modifier332            .fillMaxWidth()333            .clip(RoundedCornerShape(8.dp))334            .background(peachColors.T0.copy(alpha = 0.07f))335            .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))336            .clickable(337                interactionSource = remember { MutableInteractionSource() },338                indication = null,339                onClick = onRestore,340            )341            .padding(vertical = 8.dp, horizontal = 10.dp),342    )343}344345// ── Nav row ─────────────────────────────────────────────────────────────────346347/** Back is hidden (space reserved) on the first card; visible from card 2 on. */348internal fun onboardingBackVisible(card: Int): Boolean = card > 0349350/** The forward button reads "create vault" on the final card, else "next →". */351internal fun onboardingNextLabel(card: Int, count: Int): String =352    if (card == count - 1) "create vault" else "next →"353354@Composable355private fun NavRow(356    card: Int,357    cardCount: Int,358    onBack: () -> Unit,359    onNext: () -> Unit,360) {361    val accent = LocalPeachAccent.current362    Row(363        modifier = Modifier.fillMaxWidth(),364        verticalAlignment = Alignment.CenterVertically,365    ) {366        // Back — hidden (but space reserved) on card 1. Same min width as the367        // next box below so the dots land roughly centered either way; not a368        // fixed width, since "create vault" is longer than "next →"/"← back".369        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {370            if (onboardingBackVisible(card)) {371                Text(372                    "← back",373                    color = peachColors.T2,374                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),375                    modifier = Modifier376                        .clip(RoundedCornerShape(6.dp))377                        .clickable(onClick = onBack)378                        .padding(vertical = 6.dp, horizontal = 4.dp),379                )380            }381        }382383        // Dot indicators, centered.384        Row(385            modifier = Modifier.weight(1f),386            horizontalArrangement = Arrangement.Center,387            verticalAlignment = Alignment.CenterVertically,388        ) {389            repeat(cardCount) { i ->390                val activeDot = i == card391                Box(392                    modifier = Modifier393                        .padding(horizontal = 4.dp)394                        .size(if (activeDot) 8.dp else 6.dp)395                        .clip(RoundedCornerShape(999.dp))396                        .background(if (activeDot) accent else peachColors.T2),397                )398            }399        }400401        // Next / create vault.402        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {403            Text(404                onboardingNextLabel(card, cardCount),405                color = peachColors.T1,406                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),407                maxLines = 1,408                modifier = Modifier409                    .clip(RoundedCornerShape(6.dp))410                    .clickable(onClick = onNext)411                    .padding(vertical = 6.dp, horizontal = 4.dp),412            )413        }414    }415}416417// ── SharedPreferences contract (mirrored by the launch guard) ────────────────418419const val ONBOARD_PREFS = "peach_prefs"420const val ONBOARD_KEY = "peach_onboarded"421422/**423 * Where the app starts: onboarding runs once, only on a fresh install that has no424 * vault yet and has not completed onboarding. Otherwise go straight to the lock425 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",426 * so they land on Lock, not back through the intro.)427 */428fun onboardingStartDestination(429    hasVault: Boolean,430    onboarded: Boolean,431): com.peachpasswords.ui.PeachNav =432    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding433    else com.peachpasswords.ui.PeachNav.Lock