OnboardingScreen.kt

17.5 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    onRestore: () -> Unit = {},117    onAdopt: () -> Unit = {},118) {119    val context = LocalContext.current120    // rememberSaveable, not plain remember — navigating to "restore from121    // backup" (a real pushed route, unlike the browser's in-place view swap)122    // and back must not reset progress to card 1.123    var card by rememberSaveable { mutableIntStateOf(0) }124    val cardCount = ONBOARDING_CARDS.size125    val accent = LocalPeachAccent.current126127    val finish: () -> Unit = {128        context129            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)130            .edit()131            .putBoolean(ONBOARD_KEY, true)132            .apply()133        onFinished()134    }135    val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }136137    ShellBackground {138        RuneGridBackground(modifier = Modifier.fillMaxSize())139140        PeachWordmark(141            modifier = Modifier142                .align(Alignment.TopStart)143                .systemBarsPadding()144                .padding(start = 20.dp, top = 8.dp),145        )146147        Column(148            modifier = Modifier149                .align(Alignment.Center)150                .fillMaxHeight()151                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)152                .fillMaxWidth()153                .systemBarsPadding()154                .padding(horizontal = 28.dp),155            horizontalAlignment = Alignment.CenterHorizontally,156        ) {157            // Card sits high, nav pinned to the bottom edge — the browser's158            // flex column (header / card / nav), not a centred block that159            // leaves dead space above and below on a tall phone.160            Spacer(Modifier.height(40.dp))161            CardSwitcher(card, onClick = advance) { active ->162                GlassCardPanel {163                    CardContent(ONBOARDING_CARDS[active])164                    // The restore/adopt choice row only appears on the final165                    // card (Onboarding.tsx's `card() === CARDS.length - 1`).166                    if (active == cardCount - 1) {167                        ChoiceRow(onRestore = onRestore, onAdopt = onAdopt)168                    }169                }170            }171172            Spacer(Modifier.weight(1f))173174            NavRow(175                card = card,176                cardCount = cardCount,177                onBack = { if (card > 0) card-- },178                onNext = advance,179            )180            Spacer(Modifier.height(12.dp))181        }182    }183}184185// ── Card switcher (enter/exit transition) ───────────────────────────────────186187// CSS cubic-bezier(0.16, 1, 0.3, 1) — peach-card-in's easing (pronounced188// ease-out-expo). ease-in (translate+fade exit, peach-card-out) is CSS's189// standard cubic-bezier(0.42, 0, 1, 1).190private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)191private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)192193/**194 * Runs Onboarding.tsx's exact directional transition: the outgoing card slides195 * to translateX(-12dp) fading out over CARD_EXIT_MS=120ms (ease-in, both196 * properties together — not sequential), holds invisible for197 * CARD_ENTER_GAP_MS=60ms, then the new card content swaps in and slides from198 * translateX(12dp) to 0 fading in over 200ms (ease-out-expo).199 */200@Composable201private fun CardSwitcher(202    target: Int,203    onClick: () -> Unit,204    modifier: Modifier = Modifier,205    content: @Composable (Int) -> Unit,206) {207    val density = LocalDensity.current208    var shown by remember { mutableIntStateOf(target) }209    val translate = remember { Animatable(0f) }210    val fade = remember { Animatable(1f) }211212    LaunchedEffect(target) {213        if (target == shown) return@LaunchedEffect214        val exitPx = with(density) { (-12).dp.toPx() }215        coroutineScope {216            launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }217            launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }218        }219        delay(60)220        shown = target221        translate.snapTo(with(density) { 12.dp.toPx() })222        coroutineScope {223            launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }224            launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }225        }226    }227228    Box(229        modifier = modifier230            .fillMaxWidth()231            .offset { IntOffset(translate.value.toInt(), 0) }232            .alpha(fade.value)233            // The whole card is a single tap target that advances, matching234            // Onboarding.tsx's card-level onClick (plus Enter/Space there).235            .clickable(236                interactionSource = remember { MutableInteractionSource() },237                indication = null,238                onClick = onClick,239            ),240    ) {241        content(shown)242    }243}244245// ── Glass card panel ────────────────────────────────────────────────────────246247@Composable248private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {249    Column(250        modifier = Modifier251            .fillMaxWidth()252            .clip(RoundedCornerShape(18.dp))253            .background(peachColors.SurfaceElevated)254            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))255            .padding(horizontal = 26.dp, vertical = 44.dp),256        content = content,257    )258}259260// ── Card content ─────────────────────────────────────────────────────────────261262private fun headline() = TextStyle(263    fontFamily = PeachType.Display,264    fontStyle = FontStyle.Italic,265    fontSize = 44.sp,266    lineHeight = (44 * 1.0).sp,267)268269@Composable270private fun CardContent(copy: OnboardingCardCopy) {271    Text(copy.headline, color = peachColors.T0, style = headline())272    Spacer(Modifier.height(24.dp))273    copy.body.forEachIndexed { i, para ->274        if (i > 0) Spacer(Modifier.height(18.dp))275        Text(276            para,277            color = peachColors.T1,278            style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),279        )280    }281    copy.warning?.let { WarningRow(it) }282}283284@Composable285private fun WarningRow(text: String) {286    val accent = LocalPeachAccent.current287    Spacer(Modifier.height(20.dp))288    Column(289        modifier = Modifier290            .fillMaxWidth()291            .drawBehind {292                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)293                drawLine(294                    color = accent,295                    start = Offset(0f, 0f),296                    end = Offset(size.width, 0f),297                    strokeWidth = 1.dp.toPx(),298                    pathEffect = dash,299                )300            }301            .padding(top = 12.dp),302    ) {303        Row(verticalAlignment = Alignment.Top) {304            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))305            Spacer(Modifier.width(8.dp))306            Text(307                text,308                color = peachColors.T1,309                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),310            )311        }312    }313}314315/**316 * Card 3's two existing-vault entry points.317 */318internal const val ONBOARD_PAIR_LABEL = "Pair with another device"319internal const val ONBOARD_RESTORE_LABEL = "Restore a backup"320321@Composable322private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit) {323    Spacer(Modifier.height(18.dp))324    Text(325        "Already using Peach?",326        color = peachColors.T1,327        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),328    )329    Spacer(Modifier.height(8.dp))330    ChoiceButton(331        ONBOARD_PAIR_LABEL,332        "Scan a QR and confirm one 8-digit code.",333        onAdopt,334    )335    Spacer(Modifier.height(8.dp))336    ChoiceButton(337        ONBOARD_RESTORE_LABEL,338        "Use a Peach backup or Codex.",339        onRestore,340    )341}342343@Composable344private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit) {345    Column(346        modifier = Modifier347            .fillMaxWidth()348            .clip(RoundedCornerShape(8.dp))349            .background(peachColors.T0.copy(alpha = 0.07f))350            .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))351            .clickable(352                interactionSource = remember { MutableInteractionSource() },353                indication = null,354                onClick = onClick,355            )356            .padding(vertical = 9.dp, horizontal = 10.dp),357    ) {358        Text(359            label,360            color = peachColors.T0,361            style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),362        )363        Spacer(Modifier.height(2.dp))364        Text(365            detail,366            color = peachColors.T1,367            style = PeachType.labelMono.copy(fontSize = 9.sp),368        )369    }370}371372// ── Nav row ─────────────────────────────────────────────────────────────────373374/** Back is hidden (space reserved) on the first card; visible from card 2 on. */375internal fun onboardingBackVisible(card: Int): Boolean = card > 0376377/** The forward button reads "create vault" on the final card, else "next →". */378internal fun onboardingNextLabel(card: Int, count: Int): String =379    if (card == count - 1) "create vault" else "next →"380381@Composable382private fun NavRow(383    card: Int,384    cardCount: Int,385    onBack: () -> Unit,386    onNext: () -> Unit,387) {388    val accent = LocalPeachAccent.current389    Row(390        modifier = Modifier.fillMaxWidth(),391        verticalAlignment = Alignment.CenterVertically,392    ) {393        // Back — hidden (but space reserved) on card 1. Same min width as the394        // next box below so the dots land roughly centered either way; not a395        // fixed width, since "create vault" is longer than "next →"/"← back".396        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {397            if (onboardingBackVisible(card)) {398                Text(399                    "← back",400                    color = peachColors.T2,401                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),402                    modifier = Modifier403                        .clip(RoundedCornerShape(6.dp))404                        .clickable(onClick = onBack)405                        .padding(vertical = 6.dp, horizontal = 4.dp),406                )407            }408        }409410        // Dot indicators, centered.411        Row(412            modifier = Modifier.weight(1f),413            horizontalArrangement = Arrangement.Center,414            verticalAlignment = Alignment.CenterVertically,415        ) {416            repeat(cardCount) { i ->417                val activeDot = i == card418                Box(419                    modifier = Modifier420                        .padding(horizontal = 4.dp)421                        .size(if (activeDot) 8.dp else 6.dp)422                        .clip(RoundedCornerShape(999.dp))423                        .background(if (activeDot) accent else peachColors.T2),424                )425            }426        }427428        // Next / create vault.429        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {430            Text(431                onboardingNextLabel(card, cardCount),432                color = peachColors.T1,433                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),434                maxLines = 1,435                modifier = Modifier436                    .clip(RoundedCornerShape(6.dp))437                    .clickable(onClick = onNext)438                    .padding(vertical = 6.dp, horizontal = 4.dp),439            )440        }441    }442}443444// ── SharedPreferences contract (mirrored by the launch guard) ────────────────445446const val ONBOARD_PREFS = "peach_prefs"447const val ONBOARD_KEY = "peach_onboarded"448449/**450 * Where the app starts: onboarding runs once, only on a fresh install that has no451 * vault yet and has not completed onboarding. Otherwise go straight to the lock452 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",453 * so they land on Lock, not back through the intro.)454 */455fun onboardingStartDestination(456    hasVault: Boolean,457    onboarded: Boolean,458): com.peachpasswords.ui.PeachNav =459    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding460    else com.peachpasswords.ui.PeachNav.Lock