OnboardingScreen.kt

17.2 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.PeachWordmark50import com.peachpasswords.ui.components.RuneGridBackground51import com.peachpasswords.ui.theme.LocalPeachAccent52import com.peachpasswords.ui.theme.peachColors53import com.peachpasswords.ui.theme.PeachType54import com.peachpasswords.ui.theme.ShellBackground55import kotlinx.coroutines.coroutineScope56import kotlinx.coroutines.delay57import kotlinx.coroutines.launch5859/**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/**72 * The three onboarding cards, in order. Ported verbatim from the browser's73 * `BASE_CARDS` (popup/views/Onboarding.tsx) — the warning belongs to card 274 * ("two keys.", the master-password/recovery-words card), NOT card 3, which75 * carries no warning at all (ONBOARDING_ADDENDUM.md §1 — a prior pass had76 * this on the wrong card).77 */78val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(79    OnboardingCardCopy(80        headline = "your passwords\nlive here.\nnowhere else.",81        body = listOf(82            "No account. No server. No one but you can ever see your vault.",83            "Everything is encrypted on your device with a password only you know.",84        ),85        warning = null,86    ),87    OnboardingCardCopy(88        headline = "two keys.",89        body = listOf(90            "Your master password is yours alone. Peach never sees it.",91            "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",92        ),93        warning = "Lose both and your vault cannot be opened.",94    ),95    OnboardingCardCopy(96        headline = "everything\nyou need.",97        body = listOf(98            "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",99            "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",100        ),101        warning = null,102    ),103)104105/**106 * First-run onboarding: a three-card swipeable intro shown once before the107 * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits108 * behind the glass cards as ambient background. The "onboarded" flag lives in109 * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard110 * that decides whether to route here ([onboardingStartDestination]) is applied in111 * MainActivity — this screen only writes the flag on completion.112 */113@Composable114fun OnboardingScreen(115    onFinished: () -> Unit,116    // Card 3's "restore from backup" choice (Onboarding.tsx's onRestore prop).117    // "I already have Peach" (onAdopt) isn't wired yet — AdoptVaultView's real118    // semantics aren't confirmed on the Android side, so it isn't shown rather119    // than shipping a button that does the wrong thing or nothing at all.120    onRestore: () -> Unit = {},121) {122    val context = LocalContext.current123    // rememberSaveable, not plain remember — navigating to "restore from124    // backup" (a real pushed route, unlike the browser's in-place view swap)125    // and back must not reset progress to card 1.126    var card by rememberSaveable { mutableIntStateOf(0) }127    val cardCount = ONBOARDING_CARDS.size128    val accent = LocalPeachAccent.current129130    val finish: () -> Unit = {131        context132            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)133            .edit()134            .putBoolean(ONBOARD_KEY, true)135            .apply()136        onFinished()137    }138    val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }139140    ShellBackground {141        RuneGridBackground(modifier = Modifier.fillMaxSize())142143        PeachWordmark(144            modifier = Modifier145                .align(Alignment.TopStart)146                .systemBarsPadding()147                .padding(start = 20.dp, top = 8.dp),148        )149150        Column(151            modifier = Modifier152                // Gate 1 (wide-screen/foldable audit): matches LockScreen's fix153                // — cap the card-style content instead of letting it stretch154                // edge-to-edge on tablet/foldable widths.155                .align(Alignment.Center)156                .fillMaxHeight()157                .widthIn(max = 480.dp)158                .fillMaxWidth()159                .systemBarsPadding()160                .padding(horizontal = 28.dp),161            horizontalAlignment = Alignment.CenterHorizontally,162        ) {163            // Card sits high, nav pinned to the bottom edge — the browser's164            // flex column (header / card / nav), not a centred block that165            // leaves dead space above and below on a tall phone.166            Spacer(Modifier.height(40.dp))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.weight(1f))179180            NavRow(181                card = card,182                cardCount = cardCount,183                onBack = { if (card > 0) card-- },184                onNext = advance,185            )186            Spacer(Modifier.height(12.dp))187        }188    }189}190191// ── Card switcher (enter/exit transition) ───────────────────────────────────192193// CSS cubic-bezier(0.16, 1, 0.3, 1) — peach-card-in's easing (pronounced194// ease-out-expo). ease-in (translate+fade exit, peach-card-out) is CSS's195// standard cubic-bezier(0.42, 0, 1, 1).196private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)197private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)198199/**200 * Runs Onboarding.tsx's exact directional transition: the outgoing card slides201 * to translateX(-12dp) fading out over CARD_EXIT_MS=120ms (ease-in, both202 * properties together — not sequential), holds invisible for203 * CARD_ENTER_GAP_MS=60ms, then the new card content swaps in and slides from204 * translateX(12dp) to 0 fading in over 200ms (ease-out-expo).205 */206@Composable207private fun CardSwitcher(208    target: Int,209    onClick: () -> Unit,210    modifier: Modifier = Modifier,211    content: @Composable (Int) -> Unit,212) {213    val density = LocalDensity.current214    var shown by remember { mutableIntStateOf(target) }215    val translate = remember { Animatable(0f) }216    val fade = remember { Animatable(1f) }217218    LaunchedEffect(target) {219        if (target == shown) return@LaunchedEffect220        val exitPx = with(density) { (-12).dp.toPx() }221        coroutineScope {222            launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }223            launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }224        }225        delay(60)226        shown = target227        translate.snapTo(with(density) { 12.dp.toPx() })228        coroutineScope {229            launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }230            launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }231        }232    }233234    Box(235        modifier = modifier236            .fillMaxWidth()237            .offset { IntOffset(translate.value.toInt(), 0) }238            .alpha(fade.value)239            // The whole card is a single tap target that advances, matching240            // Onboarding.tsx's card-level onClick (plus Enter/Space there).241            .clickable(242                interactionSource = remember { MutableInteractionSource() },243                indication = null,244                onClick = onClick,245            ),246    ) {247        content(shown)248    }249}250251// ── Glass card panel ────────────────────────────────────────────────────────252253@Composable254private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {255    Column(256        modifier = Modifier257            .fillMaxWidth()258            .clip(RoundedCornerShape(18.dp))259            .background(peachColors.SurfaceElevated)260            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))261            .padding(horizontal = 26.dp, vertical = 44.dp),262        content = content,263    )264}265266// ── Card content ─────────────────────────────────────────────────────────────267268private fun headline() = TextStyle(269    fontFamily = PeachType.Display,270    fontStyle = FontStyle.Italic,271    fontSize = 44.sp,272    lineHeight = (44 * 1.0).sp,273)274275@Composable276private fun CardContent(copy: OnboardingCardCopy) {277    Text(copy.headline, color = peachColors.T0, style = headline())278    Spacer(Modifier.height(24.dp))279    copy.body.forEachIndexed { i, para ->280        if (i > 0) Spacer(Modifier.height(18.dp))281        Text(282            para,283            color = peachColors.T1,284            style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),285        )286    }287    copy.warning?.let { WarningRow(it) }288}289290@Composable291private fun WarningRow(text: String) {292    val accent = LocalPeachAccent.current293    Spacer(Modifier.height(20.dp))294    Column(295        modifier = Modifier296            .fillMaxWidth()297            .drawBehind {298                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)299                drawLine(300                    color = accent,301                    start = Offset(0f, 0f),302                    end = Offset(size.width, 0f),303                    strokeWidth = 1.dp.toPx(),304                    pathEffect = dash,305                )306            }307            .padding(top = 12.dp),308    ) {309        Row(verticalAlignment = Alignment.Top) {310            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))311            Spacer(Modifier.width(8.dp))312            Text(313                text,314                color = peachColors.T1,315                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),316            )317        }318    }319}320321/**322 * Card 3's choice row (Onboarding.tsx `peach-onboarding-choice-row`). The323 * browser also has an "I already have Peach" button here (onAdopt) — not324 * shown yet, see [OnboardingScreen]'s onRestore doc.325 */326@Composable327private fun ChoiceRow(onRestore: () -> Unit) {328    Spacer(Modifier.height(14.dp))329    Text(330        "restore from backup",331        color = peachColors.T0,332        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),333        modifier = Modifier334            .fillMaxWidth()335            .clip(RoundedCornerShape(8.dp))336            .background(peachColors.T0.copy(alpha = 0.07f))337            .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))338            .clickable(339                interactionSource = remember { MutableInteractionSource() },340                indication = null,341                onClick = onRestore,342            )343            .padding(vertical = 8.dp, horizontal = 10.dp),344    )345}346347// ── Nav row ─────────────────────────────────────────────────────────────────348349/** Back is hidden (space reserved) on the first card; visible from card 2 on. */350internal fun onboardingBackVisible(card: Int): Boolean = card > 0351352/** The forward button reads "create vault" on the final card, else "next →". */353internal fun onboardingNextLabel(card: Int, count: Int): String =354    if (card == count - 1) "create vault" else "next →"355356@Composable357private fun NavRow(358    card: Int,359    cardCount: Int,360    onBack: () -> Unit,361    onNext: () -> Unit,362) {363    val accent = LocalPeachAccent.current364    Row(365        modifier = Modifier.fillMaxWidth(),366        verticalAlignment = Alignment.CenterVertically,367    ) {368        // Back — hidden (but space reserved) on card 1. Same min width as the369        // next box below so the dots land roughly centered either way; not a370        // fixed width, since "create vault" is longer than "next →"/"← back".371        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {372            if (onboardingBackVisible(card)) {373                Text(374                    "← back",375                    color = peachColors.T2,376                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),377                    modifier = Modifier378                        .clip(RoundedCornerShape(6.dp))379                        .clickable(onClick = onBack)380                        .padding(vertical = 6.dp, horizontal = 4.dp),381                )382            }383        }384385        // Dot indicators, centered.386        Row(387            modifier = Modifier.weight(1f),388            horizontalArrangement = Arrangement.Center,389            verticalAlignment = Alignment.CenterVertically,390        ) {391            repeat(cardCount) { i ->392                val activeDot = i == card393                Box(394                    modifier = Modifier395                        .padding(horizontal = 4.dp)396                        .size(if (activeDot) 8.dp else 6.dp)397                        .clip(RoundedCornerShape(999.dp))398                        .background(if (activeDot) accent else peachColors.T2),399                )400            }401        }402403        // Next / create vault.404        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {405            Text(406                onboardingNextLabel(card, cardCount),407                color = peachColors.T1,408                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),409                maxLines = 1,410                modifier = Modifier411                    .clip(RoundedCornerShape(6.dp))412                    .clickable(onClick = onNext)413                    .padding(vertical = 6.dp, horizontal = 4.dp),414            )415        }416    }417}418419// ── SharedPreferences contract (mirrored by the launch guard) ────────────────420421const val ONBOARD_PREFS = "peach_prefs"422const val ONBOARD_KEY = "peach_onboarded"423424/**425 * Where the app starts: onboarding runs once, only on a fresh install that has no426 * vault yet and has not completed onboarding. Otherwise go straight to the lock427 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",428 * so they land on Lock, not back through the intro.)429 */430fun onboardingStartDestination(431    hasVault: Boolean,432    onboarded: Boolean,433): com.peachpasswords.ui.PeachNav =434    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding435    else com.peachpasswords.ui.PeachNav.Lock