OnboardingScreen.kt

18.9 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.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.font.FontStyle45import androidx.compose.ui.unit.IntOffset46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.em48import androidx.compose.ui.unit.sp49import androidx.compose.material3.Text50import com.peachpasswords.ui.components.PeachWordmark51import com.peachpasswords.ui.components.RuneGridBackground52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.peachColors54import com.peachpasswords.ui.theme.PeachType55import com.peachpasswords.ui.theme.ShellBackground56import kotlinx.coroutines.coroutineScope57import kotlinx.coroutines.delay58import kotlinx.coroutines.launch5960/**61 * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a62 * single source of truth (and testable). Ported from the browser onboarding63 * (`popup/views/Onboarding.tsx`); the warning sits on the final card per the64 * mobile layout, which trades the browser's prose card 3 for feature-style copy.65 */66data class OnboardingCardCopy(67    val headline: String,68    val body: List<String>,69    val warning: String?,70)7172/**73 * The three onboarding cards, in order. Ported verbatim from the browser's74 * `BASE_CARDS` (popup/views/Onboarding.tsx) — the warning belongs to card 275 * ("two keys.", the master-password/recovery-words card), NOT card 3, which76 * carries no warning at all (ONBOARDING_ADDENDUM.md §1 — a prior pass had77 * this on the wrong card).78 */79val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(80    OnboardingCardCopy(81        headline = "your passwords\nlive here.\nnowhere else.",82        body = listOf(83            "No account. No server. No one but you can ever see your vault.",84            "Everything is encrypted on your device with a password only you know.",85        ),86        warning = null,87    ),88    OnboardingCardCopy(89        headline = "two keys.",90        body = listOf(91            "Your master password is yours alone. Peach never sees it.",92            "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",93        ),94        warning = "Lose both and your vault cannot be opened.",95    ),96    OnboardingCardCopy(97        headline = "everything\nyou need.",98        body = listOf(99            "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",100            "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",101        ),102        warning = null,103    ),104)105106/**107 * First-run onboarding: a three-card swipeable intro shown once before the108 * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits109 * behind the glass cards as ambient background. The "onboarded" flag lives in110 * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard111 * that decides whether to route here ([onboardingStartDestination]) is applied in112 * MainActivity — this screen only writes the flag on completion.113 */114@Composable115fun OnboardingScreen(116    onFinished: () -> Unit,117    onRestore: () -> Unit = {},118    onAdopt: () -> Unit = {},119) {120    val context = LocalContext.current121    // rememberSaveable, not plain remember — navigating to "restore from122    // backup" (a real pushed route, unlike the browser's in-place view swap)123    // and back must not reset progress to card 1.124    var card by rememberSaveable { mutableIntStateOf(0) }125    val cardCount = ONBOARDING_CARDS.size126    val accent = LocalPeachAccent.current127128    val finish: () -> Unit = {129        context130            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)131            .edit()132            .putBoolean(ONBOARD_KEY, true)133            .apply()134        onFinished()135    }136    val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }137138    ShellBackground {139        RuneGridBackground(modifier = Modifier.fillMaxSize())140141        PeachWordmark(142            modifier = Modifier143                .align(Alignment.TopStart)144                .systemBarsPadding()145                .padding(start = 20.dp, top = 8.dp),146        )147148        Column(149            modifier = Modifier150                .align(Alignment.Center)151                .fillMaxHeight()152                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)153                .fillMaxWidth()154                .systemBarsPadding()155                .padding(horizontal = 28.dp),156            horizontalAlignment = Alignment.CenterHorizontally,157        ) {158            // Card sits high, nav pinned to the bottom edge — the browser's159            // flex column (header / card / nav), not a centred block that160            // leaves dead space above and below on a tall phone.161            Spacer(Modifier.height(40.dp))162            CardSwitcher(card, onClick = { if (card < cardCount - 1) advance() }) { active ->163                GlassCardPanel {164                    CardContent(ONBOARDING_CARDS[active])165                    // The restore/adopt choice row only appears on the final166                    // card (Onboarding.tsx's `card() === CARDS.length - 1`).167                    if (active == cardCount - 1) {168                        ChoiceRow(onRestore = onRestore, onAdopt = onAdopt, onCreate = advance)169                    }170                }171            }172173            Spacer(Modifier.weight(1f))174175            NavRow(176                card = card,177                cardCount = cardCount,178                onBack = { if (card > 0) card-- },179                onNext = advance,180            )181            Spacer(Modifier.height(12.dp))182        }183    }184}185186// ── Card switcher (enter/exit transition) ───────────────────────────────────187188// CSS cubic-bezier(0.16, 1, 0.3, 1) — peach-card-in's easing (pronounced189// ease-out-expo). ease-in (translate+fade exit, peach-card-out) is CSS's190// standard cubic-bezier(0.42, 0, 1, 1).191private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)192private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)193194/**195 * Runs Onboarding.tsx's exact directional transition: the outgoing card slides196 * to translateX(-12dp) fading out over CARD_EXIT_MS=120ms (ease-in, both197 * properties together — not sequential), holds invisible for198 * CARD_ENTER_GAP_MS=60ms, then the new card content swaps in and slides from199 * translateX(12dp) to 0 fading in over 200ms (ease-out-expo).200 */201@Composable202private fun CardSwitcher(203    target: Int,204    onClick: () -> Unit,205    modifier: Modifier = Modifier,206    content: @Composable (Int) -> Unit,207) {208    val density = LocalDensity.current209    var shown by remember { mutableIntStateOf(target) }210    val translate = remember { Animatable(0f) }211    val fade = remember { Animatable(1f) }212213    LaunchedEffect(target) {214        if (target == shown) return@LaunchedEffect215        val exitPx = with(density) { (-12).dp.toPx() }216        coroutineScope {217            launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }218            launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }219        }220        delay(60)221        shown = target222        translate.snapTo(with(density) { 12.dp.toPx() })223        coroutineScope {224            launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }225            launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }226        }227    }228229    Box(230        modifier = modifier231            .fillMaxWidth()232            .offset { IntOffset(translate.value.toInt(), 0) }233            .alpha(fade.value)234            // The whole card is a single tap target that advances, matching235            // Onboarding.tsx's card-level onClick (plus Enter/Space there).236            .clickable(237                interactionSource = remember { MutableInteractionSource() },238                indication = null,239                onClick = onClick,240            ),241    ) {242        content(shown)243    }244}245246// ── Glass card panel ────────────────────────────────────────────────────────247248@Composable249private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {250    Column(251        modifier = Modifier252            .fillMaxWidth()253            .clip(RoundedCornerShape(18.dp))254            .background(peachColors.SurfaceElevated)255            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))256            .padding(horizontal = 26.dp, vertical = 44.dp),257        content = content,258    )259}260261// ── Card content ─────────────────────────────────────────────────────────────262263private fun headline() = TextStyle(264    fontFamily = PeachType.Display,265    fontStyle = FontStyle.Italic,266    fontSize = 44.sp,267    lineHeight = (44 * 1.0).sp,268)269270@Composable271private fun CardContent(copy: OnboardingCardCopy) {272    Text(copy.headline, color = peachColors.T0, style = headline())273    Spacer(Modifier.height(24.dp))274    copy.body.forEachIndexed { i, para ->275        if (i > 0) Spacer(Modifier.height(18.dp))276        Text(277            para,278            color = peachColors.T1,279            style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),280        )281    }282    copy.warning?.let { WarningRow(it) }283}284285@Composable286private fun WarningRow(text: String) {287    val accent = LocalPeachAccent.current288    Spacer(Modifier.height(20.dp))289    Column(290        modifier = Modifier291            .fillMaxWidth()292            .drawBehind {293                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)294                drawLine(295                    color = accent,296                    start = Offset(0f, 0f),297                    end = Offset(size.width, 0f),298                    strokeWidth = 1.dp.toPx(),299                    pathEffect = dash,300                )301            }302            .padding(top = 12.dp),303    ) {304        Row(verticalAlignment = Alignment.Top) {305            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))306            Spacer(Modifier.width(8.dp))307            Text(308                text,309                color = peachColors.T1,310                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),311            )312        }313    }314}315316/**317 * Card 3's existing-vault entry points, plus the primary create-new-vault318 * action (mirrors the browser's explicit choice — a final-card tap does not319 * silently start creation).320 */321internal const val ONBOARD_CREATE_LABEL = "Create a new vault"322internal const val ONBOARD_PAIR_LABEL = "Pair with another device"323internal const val ONBOARD_RESTORE_LABEL = "Restore a backup"324325@Composable326private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit, onCreate: () -> Unit) {327    Spacer(Modifier.height(18.dp))328    ChoiceButton(329        ONBOARD_CREATE_LABEL,330        "Set your master password and start saving.",331        onCreate,332        primary = true,333    )334    Spacer(Modifier.height(8.dp))335    Text(336        "Already using Peach?",337        color = peachColors.T1,338        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),339    )340    Spacer(Modifier.height(8.dp))341    ChoiceButton(342        ONBOARD_PAIR_LABEL,343        "Scan a QR and confirm one 8-digit code.",344        onAdopt,345        pro = true,346    )347    Spacer(Modifier.height(8.dp))348    ChoiceButton(349        ONBOARD_RESTORE_LABEL,350        "Use a Peach backup or Codex.",351        onRestore,352    )353}354355@Composable356private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit, primary: Boolean = false, pro: Boolean = false) {357    val accent = LocalPeachAccent.current358    val labelColor = if (primary) Color(0f, 0f, 0f, 0.92f) else peachColors.T0359    val detailColor = if (primary) Color(0f, 0f, 0f, 0.72f) else peachColors.T1360    Column(361        modifier = Modifier362            .fillMaxWidth()363            .clip(RoundedCornerShape(8.dp))364            .background(if (primary) accent else peachColors.T0.copy(alpha = 0.07f))365            .border(1.dp, if (primary) accent else peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))366            .clickable(367                interactionSource = remember { MutableInteractionSource() },368                indication = null,369                onClick = onClick,370            )371            .padding(vertical = 9.dp, horizontal = 10.dp),372    ) {373        Row(verticalAlignment = Alignment.CenterVertically) {374            Text(375                label,376                color = labelColor,377                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),378            )379            if (pro) {380                Spacer(Modifier.width(6.dp))381                Box(382                    Modifier383                        .clip(RoundedCornerShape(4.dp))384                        .background(accent.copy(alpha = 0.16f))385                        .padding(horizontal = 5.dp, vertical = 1.dp),386                ) {387                    Text(388                        "Pro",389                        color = accent.copy(alpha = 0.85f),390                        fontFamily = PeachType.Mono,391                        fontSize = 8.sp,392                    )393                }394            }395        }396        Spacer(Modifier.height(2.dp))397        Text(398            detail,399            color = detailColor,400            style = PeachType.labelMono.copy(fontSize = 9.sp),401        )402    }403}404405// ── Nav row ─────────────────────────────────────────────────────────────────406407/** Back is hidden (space reserved) on the first card; visible from card 2 on. */408internal fun onboardingBackVisible(card: Int): Boolean = card > 0409410/** The forward button reads "create vault" on the final card, else "next →". */411internal fun onboardingNextLabel(card: Int, count: Int): String =412    if (card == count - 1) "create vault" else "next →"413414@Composable415private fun NavRow(416    card: Int,417    cardCount: Int,418    onBack: () -> Unit,419    onNext: () -> Unit,420) {421    val accent = LocalPeachAccent.current422    Row(423        modifier = Modifier.fillMaxWidth(),424        verticalAlignment = Alignment.CenterVertically,425    ) {426        // Back — hidden (but space reserved) on card 1. Same min width as the427        // next box below so the dots land roughly centered either way; not a428        // fixed width, since "create vault" is longer than "next →"/"← back".429        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {430            if (onboardingBackVisible(card)) {431                Text(432                    "← back",433                    color = peachColors.T2,434                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),435                    modifier = Modifier436                        .clip(RoundedCornerShape(6.dp))437                        .clickable(onClick = onBack)438                        .padding(vertical = 6.dp, horizontal = 4.dp),439                )440            }441        }442443        // Dot indicators, centered.444        Row(445            modifier = Modifier.weight(1f),446            horizontalArrangement = Arrangement.Center,447            verticalAlignment = Alignment.CenterVertically,448        ) {449            repeat(cardCount) { i ->450                val activeDot = i == card451                Box(452                    modifier = Modifier453                        .padding(horizontal = 4.dp)454                        .size(if (activeDot) 8.dp else 6.dp)455                        .clip(RoundedCornerShape(999.dp))456                        .background(if (activeDot) accent else peachColors.T2),457                )458            }459        }460461        // Next / create vault.462        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {463            Text(464                onboardingNextLabel(card, cardCount),465                color = peachColors.T1,466                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),467                maxLines = 1,468                modifier = Modifier469                    .clip(RoundedCornerShape(6.dp))470                    .clickable(onClick = onNext)471                    .padding(vertical = 6.dp, horizontal = 4.dp),472            )473        }474    }475}476477// ── SharedPreferences contract (mirrored by the launch guard) ────────────────478479const val ONBOARD_PREFS = "peach_prefs"480const val ONBOARD_KEY = "peach_onboarded"481482/**483 * Where the app starts: onboarding runs once, only on a fresh install that has no484 * vault yet and has not completed onboarding. Otherwise go straight to the lock485 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",486 * so they land on Lock, not back through the intro.)487 */488fun onboardingStartDestination(489    hasVault: Boolean,490    onboarded: Boolean,491): com.peachpasswords.ui.PeachNav =492    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding493    else com.peachpasswords.ui.PeachNav.Lock