OnboardingScreen.kt

13.7 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.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Row13import androidx.compose.foundation.layout.Spacer14import androidx.compose.foundation.layout.fillMaxHeight15import androidx.compose.foundation.layout.fillMaxSize16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.height18import androidx.compose.foundation.layout.offset19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.size21import androidx.compose.foundation.layout.systemBarsPadding22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.layout.widthIn24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.runtime.Composable26import androidx.compose.runtime.LaunchedEffect27import androidx.compose.runtime.getValue28import androidx.compose.runtime.mutableIntStateOf29import androidx.compose.runtime.remember30import androidx.compose.runtime.setValue31import androidx.compose.ui.Alignment32import androidx.compose.ui.Modifier33import androidx.compose.ui.draw.alpha34import androidx.compose.ui.draw.clip35import androidx.compose.ui.draw.drawBehind36import androidx.compose.ui.geometry.Offset37import androidx.compose.ui.graphics.PathEffect38import androidx.compose.ui.platform.LocalContext39import androidx.compose.ui.platform.LocalDensity40import androidx.compose.ui.text.TextStyle41import androidx.compose.ui.text.font.FontStyle42import androidx.compose.ui.unit.IntOffset43import androidx.compose.ui.unit.dp44import androidx.compose.ui.unit.em45import androidx.compose.ui.unit.sp46import androidx.compose.material3.Text47import com.peachpasswords.ui.components.RuneGridBackground48import com.peachpasswords.ui.theme.LocalPeachAccent49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.ui.theme.PeachType51import com.peachpasswords.ui.theme.ShellBackground5253/**54 * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a55 * single source of truth (and testable). Ported from the browser onboarding56 * (`popup/views/Onboarding.tsx`); the warning sits on the final card per the57 * mobile layout, which trades the browser's prose card 3 for feature-style copy.58 */59data class OnboardingCardCopy(60    val headline: String,61    val body: List<String>,62    val warning: String?,63)6465/** The three onboarding cards, in order. */66val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(67    OnboardingCardCopy(68        headline = "your passwords\nlive here.\nnowhere else.",69        body = listOf(70            "No account. No server. No one but you can ever see your vault.",71            "Everything is encrypted on your device with a password only you know.",72        ),73        warning = null,74    ),75    OnboardingCardCopy(76        headline = "two keys.",77        body = listOf(78            "Your master password is yours alone. Peach never sees it.",79            "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",80        ),81        warning = null,82    ),83    OnboardingCardCopy(84        headline = "everything\nyou need.",85        body = listOf(86            "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",87            "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",88        ),89        warning = "Lose both your password and your recovery words and your vault cannot be opened.",90    ),91)9293/**94 * First-run onboarding: a three-card swipeable intro shown once before the95 * lock screen's create-vault flow. A drifting field of PeachScript glyphs sits96 * behind the glass cards as ambient background. The "onboarded" flag lives in97 * SharedPreferences("peach_prefs") under key "peach_onboarded"; the launch guard98 * that decides whether to route here ([onboardingStartDestination]) is applied in99 * MainActivity — this screen only writes the flag on completion.100 */101@Composable102fun OnboardingScreen(103    onFinished: () -> Unit,104) {105    val context = LocalContext.current106    var card by remember { mutableIntStateOf(0) }107    val cardCount = ONBOARDING_CARDS.size108    val accent = LocalPeachAccent.current109110    val finish: () -> Unit = {111        context112            .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)113            .edit()114            .putBoolean(ONBOARD_KEY, true)115            .apply()116        onFinished()117    }118119    ShellBackground {120        RuneGridBackground(modifier = Modifier.fillMaxSize())121122        // Wordmark, top-left — matches LockScreen.123        Text(124            text = "peach",125            color = accent,126            style = PeachType.wordmark,127            modifier = Modifier128                .align(Alignment.TopStart)129                .systemBarsPadding()130                .padding(start = 20.dp, top = 8.dp),131        )132133        Column(134            modifier = Modifier135                // Gate 1 (wide-screen/foldable audit): matches LockScreen's fix136                // — cap the card-style content instead of letting it stretch137                // edge-to-edge on tablet/foldable widths.138                .align(Alignment.Center)139                .fillMaxHeight()140                .widthIn(max = 480.dp)141                .fillMaxWidth()142                .systemBarsPadding()143                .padding(horizontal = 28.dp),144            verticalArrangement = Arrangement.Center,145            horizontalAlignment = Alignment.CenterHorizontally,146        ) {147            CardSwitcher(card) { active ->148                GlassCardPanel {149                    CardContent(ONBOARDING_CARDS[active])150                }151            }152153            Spacer(Modifier.height(28.dp))154155            NavRow(156                card = card,157                cardCount = cardCount,158                onBack = { if (card > 0) card-- },159                onNext = { if (card < cardCount - 1) card++ else finish() },160            )161        }162    }163}164165// ── Card switcher (enter/exit transition) ───────────────────────────────────166167/**168 * Crossfade-style switcher that runs the spec's directional transition:169 *   exit  translateX(0 → -12.dp) + alpha(1 → 0) over 120ms ease-in170 *   enter translateX(12.dp → 0)  + alpha(0 → 1) over 200ms spring171 *172 * Implemented as a two-phase animation keyed on [target]: the old card slides173 * left and fades, then the new card slides in from the right.174 */175@Composable176private fun CardSwitcher(177    target: Int,178    content: @Composable (Int) -> Unit,179) {180    val density = LocalDensity.current181    var shown by remember { mutableIntStateOf(target) }182    val translate = remember { Animatable(0f) }183    val fade = remember { Animatable(1f) }184185    LaunchedEffect(target) {186        if (target == shown) return@LaunchedEffect187        // Exit current card.188        val exitPx = with(density) { (-12).dp.toPx() }189        translate.animateTo(exitPx, tween(120))190        fade.animateTo(0f, tween(120))191        // Swap, then enter from the right.192        shown = target193        translate.snapTo(with(density) { 12.dp.toPx() })194        // Spring per spec; PeachMotion.Spring is the app's shared spring easing.195        translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring))196    }197    LaunchedEffect(shown) {198        if (fade.value < 1f) fade.animateTo(1f, tween(200))199    }200201    Box(202        modifier = Modifier203            .fillMaxWidth()204            .offset { IntOffset(translate.value.toInt(), 0) }205            .alpha(fade.value),206    ) {207        content(shown)208    }209}210211// ── Glass card panel ────────────────────────────────────────────────────────212213@Composable214private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {215    Column(216        modifier = Modifier217            .fillMaxWidth()218            .clip(RoundedCornerShape(18.dp))219            .background(peachColors.SurfaceElevated)220            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))221            .padding(horizontal = 24.dp, vertical = 28.dp),222        content = content,223    )224}225226// ── Card content ─────────────────────────────────────────────────────────────227228private fun headline() = TextStyle(229    fontFamily = PeachType.Display,230    fontStyle = FontStyle.Italic,231    fontSize = 40.sp,232    lineHeight = (40 * 0.98).sp,233)234235@Composable236private fun CardContent(copy: OnboardingCardCopy) {237    Text(copy.headline, color = peachColors.T0, style = headline())238    Spacer(Modifier.height(18.dp))239    copy.body.forEachIndexed { i, para ->240        if (i > 0) Spacer(Modifier.height(12.dp))241        Text(242            para,243            color = peachColors.T1,244            style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),245        )246    }247    copy.warning?.let { WarningRow(it) }248}249250@Composable251private fun WarningRow(text: String) {252    val accent = LocalPeachAccent.current253    Spacer(Modifier.height(20.dp))254    Column(255        modifier = Modifier256            .fillMaxWidth()257            .drawBehind {258                val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)259                drawLine(260                    color = accent,261                    start = Offset(0f, 0f),262                    end = Offset(size.width, 0f),263                    strokeWidth = 1.dp.toPx(),264                    pathEffect = dash,265                )266            }267            .padding(top = 12.dp),268    ) {269        Row(verticalAlignment = Alignment.Top) {270            Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))271            Spacer(Modifier.width(8.dp))272            Text(273                text,274                color = peachColors.T1,275                style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),276            )277        }278    }279}280281// ── Nav row ─────────────────────────────────────────────────────────────────282283/** Back is hidden (space reserved) on the first card; visible from card 2 on. */284internal fun onboardingBackVisible(card: Int): Boolean = card > 0285286/** The forward button reads "Get started" on the final card, else "next". */287internal fun onboardingNextLabel(card: Int, count: Int): String =288    if (card == count - 1) "Get started" else "next"289290@Composable291private fun NavRow(292    card: Int,293    cardCount: Int,294    onBack: () -> Unit,295    onNext: () -> Unit,296) {297    val accent = LocalPeachAccent.current298    Row(299        modifier = Modifier.fillMaxWidth(),300        verticalAlignment = Alignment.CenterVertically,301    ) {302        // Back — hidden (but space reserved) on card 1.303        Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.CenterStart) {304            if (onboardingBackVisible(card)) {305                Text(306                    "back",307                    color = peachColors.T2,308                    style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),309                    modifier = Modifier310                        .clip(RoundedCornerShape(6.dp))311                        .clickable(onClick = onBack)312                        .padding(vertical = 6.dp, horizontal = 4.dp),313                )314            }315        }316317        // Dot indicators, centered.318        Row(319            modifier = Modifier.weight(1f),320            horizontalArrangement = Arrangement.Center,321            verticalAlignment = Alignment.CenterVertically,322        ) {323            repeat(cardCount) { i ->324                val activeDot = i == card325                Box(326                    modifier = Modifier327                        .padding(horizontal = 4.dp)328                        .size(if (activeDot) 8.dp else 6.dp)329                        .clip(RoundedCornerShape(999.dp))330                        .background(if (activeDot) accent else peachColors.T2),331                )332            }333        }334335        // Next / Get started.336        Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {337            Text(338                onboardingNextLabel(card, cardCount),339                color = peachColors.T1,340                style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),341                modifier = Modifier342                    .clip(RoundedCornerShape(6.dp))343                    .clickable(onClick = onNext)344                    .padding(vertical = 6.dp, horizontal = 4.dp),345            )346        }347    }348}349350// ── SharedPreferences contract (mirrored by the launch guard) ────────────────351352const val ONBOARD_PREFS = "peach_prefs"353const val ONBOARD_KEY = "peach_onboarded"354355/**356 * Where the app starts: onboarding runs once, only on a fresh install that has no357 * vault yet and has not completed onboarding. Otherwise go straight to the lock358 * screen. (A user who quit mid-vault-creation has no vault but is "onboarded",359 * so they land on Lock, not back through the intro.)360 */361fun onboardingStartDestination(362    hasVault: Boolean,363    onboarded: Boolean,364): com.peachpasswords.ui.PeachNav =365    if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding366    else com.peachpasswords.ui.PeachNav.Lock