OnboardingScreen.kt

18.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    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        pro = true,335    )336    Spacer(Modifier.height(8.dp))337    ChoiceButton(338        ONBOARD_RESTORE_LABEL,339        "Use a Peach backup or Codex.",340        onRestore,341    )342}343344@Composable345private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit, pro: Boolean = false) {346    Column(347        modifier = Modifier348            .fillMaxWidth()349            .clip(RoundedCornerShape(8.dp))350            .background(peachColors.T0.copy(alpha = 0.07f))351            .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))352            .clickable(353                interactionSource = remember { MutableInteractionSource() },354                indication = null,355                onClick = onClick,356            )357            .padding(vertical = 9.dp, horizontal = 10.dp),358    ) {359        Row(verticalAlignment = Alignment.CenterVertically) {360            Text(361                label,362                color = peachColors.T0,363                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),364            )365            if (pro) {366                Spacer(Modifier.width(6.dp))367                val accent = LocalPeachAccent.current368                Box(369                    Modifier370                        .clip(RoundedCornerShape(4.dp))371                        .background(accent.copy(alpha = 0.16f))372                        .padding(horizontal = 5.dp, vertical = 1.dp),373                ) {374                    Text(375                        "Pro",376                        color = accent.copy(alpha = 0.85f),377                        fontFamily = PeachType.Mono,378                        fontSize = 8.sp,379                    )380                }381            }382        }383        Spacer(Modifier.height(2.dp))384        Text(385            detail,386            color = peachColors.T1,387            style = PeachType.labelMono.copy(fontSize = 9.sp),388        )389    }390}391392// ── Nav row ─────────────────────────────────────────────────────────────────393394/** Back is hidden (space reserved) on the first card; visible from card 2 on. */395internal fun onboardingBackVisible(card: Int): Boolean = card > 0396397/** The forward button reads "create vault" on the final card, else "next →". */398internal fun onboardingNextLabel(card: Int, count: Int): String =399    if (card == count - 1) "create vault" else "next →"400401@Composable402private fun NavRow(403    card: Int,404    cardCount: Int,405    onBack: () -> Unit,406    onNext: () -> Unit,407) {408    val accent = LocalPeachAccent.current409    Row(410        modifier = Modifier.fillMaxWidth(),411        verticalAlignment = Alignment.CenterVertically,412    ) {413        // Back — hidden (but space reserved) on card 1. Same min width as the414        // next box below so the dots land roughly centered either way; not a415        // fixed width, since "create vault" is longer than "next →"/"← back".416        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {417            if (onboardingBackVisible(card)) {418                Text(419                    "← back",420                    color = peachColors.T2,421                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),422                    modifier = Modifier423                        .clip(RoundedCornerShape(6.dp))424                        .clickable(onClick = onBack)425                        .padding(vertical = 6.dp, horizontal = 4.dp),426                )427            }428        }429430        // Dot indicators, centered.431        Row(432            modifier = Modifier.weight(1f),433            horizontalArrangement = Arrangement.Center,434            verticalAlignment = Alignment.CenterVertically,435        ) {436            repeat(cardCount) { i ->437                val activeDot = i == card438                Box(439                    modifier = Modifier440                        .padding(horizontal = 4.dp)441                        .size(if (activeDot) 8.dp else 6.dp)442                        .clip(RoundedCornerShape(999.dp))443                        .background(if (activeDot) accent else peachColors.T2),444                )445            }446        }447448        // Next / create vault.449        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {450            Text(451                onboardingNextLabel(card, cardCount),452                color = peachColors.T1,453                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),454                maxLines = 1,455                modifier = Modifier456                    .clip(RoundedCornerShape(6.dp))457                    .clickable(onClick = onNext)458                    .padding(vertical = 6.dp, horizontal = 4.dp),459            )460        }461    }462}463464// ── SharedPreferences contract (mirrored by the launch guard) ────────────────465466const val ONBOARD_PREFS = "peach_prefs"467const val ONBOARD_KEY = "peach_onboarded"468469/**470 * Where the app starts: onboarding runs once, only on a fresh install that has no471 * vault yet and has not completed onboarding. Otherwise go straight to the lock472 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",473 * so they land on Lock, not back through the intro.)474 */475fun onboardingStartDestination(476    hasVault: Boolean,477    onboarded: Boolean,478): com.peachpasswords.ui.PeachNav =479    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding480    else com.peachpasswords.ui.PeachNav.Lock