OnboardingScreen.kt

16.8 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 */318@Composable319private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit) {320    Spacer(Modifier.height(14.dp))321    ChoiceButton("I already have Peach", onAdopt)322    Spacer(Modifier.height(8.dp))323    ChoiceButton("restore from backup", onRestore)324}325326@Composable327private fun ChoiceButton(label: String, onClick: () -> Unit) {328    Text(329        label,330        color = peachColors.T0,331        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),332        modifier = Modifier333            .fillMaxWidth()334            .clip(RoundedCornerShape(8.dp))335            .background(peachColors.T0.copy(alpha = 0.07f))336            .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))337            .clickable(338                interactionSource = remember { MutableInteractionSource() },339                indication = null,340                onClick = onClick,341            )342            .padding(vertical = 8.dp, horizontal = 10.dp),343    )344}345346// ── Nav row ─────────────────────────────────────────────────────────────────347348/** Back is hidden (space reserved) on the first card; visible from card 2 on. */349internal fun onboardingBackVisible(card: Int): Boolean = card > 0350351/** The forward button reads "create vault" on the final card, else "next →". */352internal fun onboardingNextLabel(card: Int, count: Int): String =353    if (card == count - 1) "create vault" else "next →"354355@Composable356private fun NavRow(357    card: Int,358    cardCount: Int,359    onBack: () -> Unit,360    onNext: () -> Unit,361) {362    val accent = LocalPeachAccent.current363    Row(364        modifier = Modifier.fillMaxWidth(),365        verticalAlignment = Alignment.CenterVertically,366    ) {367        // Back — hidden (but space reserved) on card 1. Same min width as the368        // next box below so the dots land roughly centered either way; not a369        // fixed width, since "create vault" is longer than "next →"/"← back".370        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {371            if (onboardingBackVisible(card)) {372                Text(373                    "← back",374                    color = peachColors.T2,375                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),376                    modifier = Modifier377                        .clip(RoundedCornerShape(6.dp))378                        .clickable(onClick = onBack)379                        .padding(vertical = 6.dp, horizontal = 4.dp),380                )381            }382        }383384        // Dot indicators, centered.385        Row(386            modifier = Modifier.weight(1f),387            horizontalArrangement = Arrangement.Center,388            verticalAlignment = Alignment.CenterVertically,389        ) {390            repeat(cardCount) { i ->391                val activeDot = i == card392                Box(393                    modifier = Modifier394                        .padding(horizontal = 4.dp)395                        .size(if (activeDot) 8.dp else 6.dp)396                        .clip(RoundedCornerShape(999.dp))397                        .background(if (activeDot) accent else peachColors.T2),398                )399            }400        }401402        // Next / create vault.403        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {404            Text(405                onboardingNextLabel(card, cardCount),406                color = peachColors.T1,407                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),408                maxLines = 1,409                modifier = Modifier410                    .clip(RoundedCornerShape(6.dp))411                    .clickable(onClick = onNext)412                    .padding(vertical = 6.dp, horizontal = 4.dp),413            )414        }415    }416}417418// ── SharedPreferences contract (mirrored by the launch guard) ────────────────419420const val ONBOARD_PREFS = "peach_prefs"421const val ONBOARD_KEY = "peach_onboarded"422423/**424 * Where the app starts: onboarding runs once, only on a fresh install that has no425 * vault yet and has not completed onboarding. Otherwise go straight to the lock426 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",427 * so they land on Lock, not back through the intro.)428 */429fun onboardingStartDestination(430    hasVault: Boolean,431    onboarded: Boolean,432): com.peachpasswords.ui.PeachNav =433    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding434    else com.peachpasswords.ui.PeachNav.Lock