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.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.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.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 /** * 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. */ 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 = null, ), 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 = "Lose both your password and your recovery words and your vault cannot be opened.", ), ) /** * 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, ) { val context = LocalContext.current var card by remember { 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() } ShellBackground { RuneGridBackground(modifier = Modifier.fillMaxSize()) // Wordmark, top-left — matches LockScreen. Text( text = "peach", color = accent, style = PeachType.wordmark, modifier = Modifier .align(Alignment.TopStart) .systemBarsPadding() .padding(start = 20.dp, top = 8.dp), ) Column( modifier = Modifier // Gate 1 (wide-screen/foldable audit): matches LockScreen's fix // — cap the card-style content instead of letting it stretch // edge-to-edge on tablet/foldable widths. .align(Alignment.Center) .fillMaxHeight() .widthIn(max = 480.dp) .fillMaxWidth() .systemBarsPadding() .padding(horizontal = 28.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { CardSwitcher(card) { active -> GlassCardPanel { CardContent(ONBOARDING_CARDS[active]) } } Spacer(Modifier.height(28.dp)) NavRow( card = card, cardCount = cardCount, onBack = { if (card > 0) card-- }, onNext = { if (card < cardCount - 1) card++ else finish() }, ) } } } // ── Card switcher (enter/exit transition) ─────────────────────────────────── /** * Crossfade-style switcher that runs the spec's directional transition: * exit translateX(0 → -12.dp) + alpha(1 → 0) over 120ms ease-in * enter translateX(12.dp → 0) + alpha(0 → 1) over 200ms spring * * Implemented as a two-phase animation keyed on [target]: the old card slides * left and fades, then the new card slides in from the right. */ @Composable private fun CardSwitcher( target: Int, 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 // Exit current card. val exitPx = with(density) { (-12).dp.toPx() } translate.animateTo(exitPx, tween(120)) fade.animateTo(0f, tween(120)) // Swap, then enter from the right. shown = target translate.snapTo(with(density) { 12.dp.toPx() }) // Spring per spec; PeachMotion.Spring is the app's shared spring easing. translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring)) } LaunchedEffect(shown) { if (fade.value < 1f) fade.animateTo(1f, tween(200)) } Box( modifier = Modifier .fillMaxWidth() .offset { IntOffset(translate.value.toInt(), 0) } .alpha(fade.value), ) { 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 = 24.dp, vertical = 28.dp), content = content, ) } // ── Card content ───────────────────────────────────────────────────────────── private fun headline() = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 40.sp, lineHeight = (40 * 0.98).sp, ) @Composable private fun CardContent(copy: OnboardingCardCopy) { Text(copy.headline, color = peachColors.T0, style = headline()) Spacer(Modifier.height(18.dp)) copy.body.forEachIndexed { i, para -> if (i > 0) Spacer(Modifier.height(12.dp)) Text( para, color = peachColors.T1, style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).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), ) } } } // ── 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 "Get started" on the final card, else "next". */ internal fun onboardingNextLabel(card: Int, count: Int): String = if (card == count - 1) "Get started" 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. Box(modifier = Modifier.width(64.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 / Get started. Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) { Text( onboardingNextLabel(card, cardCount), color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em), 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