package com.peachpasswords.ui.onboarding import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.material3.Text import com.peachpasswords.ui.components.PeachWordmark import com.peachpasswords.ui.components.RuneGridBackground import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a * single source of truth (and testable). Ported from the browser onboarding * (`popup/views/Onboarding.tsx`); the warning sits on the final card per the * mobile layout, which trades the browser's prose card 3 for feature-style copy. */ data class OnboardingCardCopy( val headline: String, val body: List, val warning: String?, ) /** * The three onboarding cards, in order. Ported verbatim from the browser's * `BASE_CARDS` (popup/views/Onboarding.tsx) — the warning belongs to card 2 * ("two keys.", the master-password/recovery-words card), NOT card 3, which * carries no warning at all (ONBOARDING_ADDENDUM.md §1 — a prior pass had * this on the wrong card). */ val ONBOARDING_CARDS: List = listOf( OnboardingCardCopy( headline = "your passwords\nlive here.\nnowhere else.", body = listOf( "No account. No server. No one but you can ever see your vault.", "Everything is encrypted on your device with a password only you know.", ), warning = null, ), OnboardingCardCopy( headline = "two keys.", body = listOf( "Your master password is yours alone. Peach never sees it.", "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.", ), warning = "Lose both and your vault cannot be opened.", ), OnboardingCardCopy( headline = "everything\nyou need.", body = listOf( "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.", "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.", ), warning = null, ), ) /** * First-run onboarding: a three-card swipeable intro shown once before the * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits * behind the glass cards as ambient background. The "onboarded" flag lives in * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard * that decides whether to route here ([onboardingStartDestination]) is applied in * MainActivity — this screen only writes the flag on completion. */ @Composable fun OnboardingScreen( onFinished: () -> Unit, onRestore: () -> Unit = {}, onAdopt: () -> Unit = {}, ) { val context = LocalContext.current // rememberSaveable, not plain remember — navigating to "restore from // backup" (a real pushed route, unlike the browser's in-place view swap) // and back must not reset progress to card 1. var card by rememberSaveable { mutableIntStateOf(0) } val cardCount = ONBOARDING_CARDS.size val accent = LocalPeachAccent.current val finish: () -> Unit = { context .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE) .edit() .putBoolean(ONBOARD_KEY, true) .apply() onFinished() } val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() } ShellBackground { RuneGridBackground(modifier = Modifier.fillMaxSize()) PeachWordmark( modifier = Modifier .align(Alignment.TopStart) .systemBarsPadding() .padding(start = 20.dp, top = 8.dp), ) Column( modifier = Modifier .align(Alignment.Center) .fillMaxHeight() .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH) .fillMaxWidth() .systemBarsPadding() .padding(horizontal = 28.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // Card sits high, nav pinned to the bottom edge — the browser's // flex column (header / card / nav), not a centred block that // leaves dead space above and below on a tall phone. Spacer(Modifier.height(40.dp)) CardSwitcher(card, onClick = advance) { active -> GlassCardPanel { CardContent(ONBOARDING_CARDS[active]) // The restore/adopt choice row only appears on the final // card (Onboarding.tsx's `card() === CARDS.length - 1`). if (active == cardCount - 1) { ChoiceRow(onRestore = onRestore, onAdopt = onAdopt) } } } Spacer(Modifier.weight(1f)) NavRow( card = card, cardCount = cardCount, onBack = { if (card > 0) card-- }, onNext = advance, ) Spacer(Modifier.height(12.dp)) } } } // ── Card switcher (enter/exit transition) ─────────────────────────────────── // CSS cubic-bezier(0.16, 1, 0.3, 1) — peach-card-in's easing (pronounced // ease-out-expo). ease-in (translate+fade exit, peach-card-out) is CSS's // standard cubic-bezier(0.42, 0, 1, 1). private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f) private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f) /** * Runs Onboarding.tsx's exact directional transition: the outgoing card slides * to translateX(-12dp) fading out over CARD_EXIT_MS=120ms (ease-in, both * properties together — not sequential), holds invisible for * CARD_ENTER_GAP_MS=60ms, then the new card content swaps in and slides from * translateX(12dp) to 0 fading in over 200ms (ease-out-expo). */ @Composable private fun CardSwitcher( target: Int, onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable (Int) -> Unit, ) { val density = LocalDensity.current var shown by remember { mutableIntStateOf(target) } val translate = remember { Animatable(0f) } val fade = remember { Animatable(1f) } LaunchedEffect(target) { if (target == shown) return@LaunchedEffect val exitPx = with(density) { (-12).dp.toPx() } coroutineScope { launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) } launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) } } delay(60) shown = target translate.snapTo(with(density) { 12.dp.toPx() }) coroutineScope { launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) } launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) } } } Box( modifier = modifier .fillMaxWidth() .offset { IntOffset(translate.value.toInt(), 0) } .alpha(fade.value) // The whole card is a single tap target that advances, matching // Onboarding.tsx's card-level onClick (plus Enter/Space there). .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick, ), ) { content(shown) } } // ── Glass card panel ──────────────────────────────────────────────────────── @Composable private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .padding(horizontal = 26.dp, vertical = 44.dp), content = content, ) } // ── Card content ───────────────────────────────────────────────────────────── private fun headline() = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp, lineHeight = (44 * 1.0).sp, ) @Composable private fun CardContent(copy: OnboardingCardCopy) { Text(copy.headline, color = peachColors.T0, style = headline()) Spacer(Modifier.height(24.dp)) copy.body.forEachIndexed { i, para -> if (i > 0) Spacer(Modifier.height(18.dp)) Text( para, color = peachColors.T1, style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp), ) } copy.warning?.let { WarningRow(it) } } @Composable private fun WarningRow(text: String) { val accent = LocalPeachAccent.current Spacer(Modifier.height(20.dp)) Column( modifier = Modifier .fillMaxWidth() .drawBehind { val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f) drawLine( color = accent, start = Offset(0f, 0f), end = Offset(size.width, 0f), strokeWidth = 1.dp.toPx(), pathEffect = dash, ) } .padding(top = 12.dp), ) { Row(verticalAlignment = Alignment.Top) { Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp)) Spacer(Modifier.width(8.dp)) Text( text, color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp), ) } } } /** * Card 3's two existing-vault entry points. */ internal const val ONBOARD_PAIR_LABEL = "Pair with another device" internal const val ONBOARD_RESTORE_LABEL = "Restore a backup" @Composable private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit) { Spacer(Modifier.height(18.dp)) Text( "Already using Peach?", color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em), ) Spacer(Modifier.height(8.dp)) ChoiceButton( ONBOARD_PAIR_LABEL, "Scan a QR and confirm one 8-digit code.", onAdopt, ) Spacer(Modifier.height(8.dp)) ChoiceButton( ONBOARD_RESTORE_LABEL, "Use a Peach backup or Codex.", onRestore, ) } @Composable private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(peachColors.T0.copy(alpha = 0.07f)) .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick, ) .padding(vertical = 9.dp, horizontal = 10.dp), ) { Text( label, color = peachColors.T0, style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em), ) Spacer(Modifier.height(2.dp)) Text( detail, color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 9.sp), ) } } // ── Nav row ───────────────────────────────────────────────────────────────── /** Back is hidden (space reserved) on the first card; visible from card 2 on. */ internal fun onboardingBackVisible(card: Int): Boolean = card > 0 /** The forward button reads "create vault" on the final card, else "next →". */ internal fun onboardingNextLabel(card: Int, count: Int): String = if (card == count - 1) "create vault" else "next →" @Composable private fun NavRow( card: Int, cardCount: Int, onBack: () -> Unit, onNext: () -> Unit, ) { val accent = LocalPeachAccent.current Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { // Back — hidden (but space reserved) on card 1. Same min width as the // next box below so the dots land roughly centered either way; not a // fixed width, since "create vault" is longer than "next →"/"← back". Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) { if (onboardingBackVisible(card)) { Text( "← back", color = peachColors.T2, style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em), modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable(onClick = onBack) .padding(vertical = 6.dp, horizontal = 4.dp), ) } } // Dot indicators, centered. Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { repeat(cardCount) { i -> val activeDot = i == card Box( modifier = Modifier .padding(horizontal = 4.dp) .size(if (activeDot) 8.dp else 6.dp) .clip(RoundedCornerShape(999.dp)) .background(if (activeDot) accent else peachColors.T2), ) } } // Next / create vault. Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) { Text( onboardingNextLabel(card, cardCount), color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em), maxLines = 1, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable(onClick = onNext) .padding(vertical = 6.dp, horizontal = 4.dp), ) } } } // ── SharedPreferences contract (mirrored by the launch guard) ──────────────── const val ONBOARD_PREFS = "peach_prefs" const val ONBOARD_KEY = "peach_onboarded" /** * Where the app starts: onboarding runs once, only on a fresh install that has no * vault yet and has not completed onboarding. Otherwise go straight to the lock * screen. (A user who quit mid-vault-creation has no vault but is "onboarded", * so they land on Lock, not back through the intro.) */ fun onboardingStartDestination( hasVault: Boolean, onboarded: Boolean, ): com.peachpasswords.ui.PeachNav = if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding else com.peachpasswords.ui.PeachNav.Lock