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.fillMaxSize15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.height17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.padding19import androidx.compose.foundation.layout.size20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.shape.RoundedCornerShape23import androidx.compose.runtime.Composable24import androidx.compose.runtime.LaunchedEffect25import androidx.compose.runtime.getValue26import androidx.compose.runtime.mutableIntStateOf27import androidx.compose.runtime.remember28import androidx.compose.runtime.setValue29import androidx.compose.ui.Alignment30import androidx.compose.ui.Modifier31import androidx.compose.ui.draw.alpha32import androidx.compose.ui.draw.clip33import androidx.compose.ui.draw.drawBehind34import androidx.compose.ui.geometry.Offset35import androidx.compose.ui.graphics.PathEffect36import androidx.compose.ui.platform.LocalContext37import androidx.compose.ui.platform.LocalDensity38import androidx.compose.ui.text.TextStyle39import androidx.compose.ui.text.font.FontStyle40import androidx.compose.ui.unit.IntOffset41import androidx.compose.ui.unit.dp42import androidx.compose.ui.unit.em43import androidx.compose.ui.unit.sp44import androidx.compose.material3.Text45import com.peachpasswords.ui.components.RuneGridBackground46import com.peachpasswords.ui.theme.LocalPeachAccent47import com.peachpasswords.ui.theme.peachColors48import com.peachpasswords.ui.theme.PeachType49import com.peachpasswords.ui.theme.ShellBackground5051525354555657data class OnboardingCardCopy(58 val headline: String,59 val body: List<String>,60 val warning: String?,61)626364val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(65 OnboardingCardCopy(66 headline = "your passwords\nlive here.\nnowhere else.",67 body = listOf(68 "No account. No server. No one but you can ever see your vault.",69 "Everything is encrypted on your device with a password only you know.",70 ),71 warning = null,72 ),73 OnboardingCardCopy(74 headline = "two keys.",75 body = listOf(76 "Your master password is yours alone. Peach never sees it.",77 "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",78 ),79 warning = null,80 ),81 OnboardingCardCopy(82 headline = "everything\nyou need.",83 body = listOf(84 "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",85 "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",86 ),87 warning = "Lose both your password and your recovery words and your vault cannot be opened.",88 ),89)90919293949596979899@Composable100fun OnboardingScreen(101 onFinished: () -> Unit,102) {103 val context = LocalContext.current104 var card by remember { mutableIntStateOf(0) }105 val cardCount = ONBOARDING_CARDS.size106 val accent = LocalPeachAccent.current107108 val finish: () -> Unit = {109 context110 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)111 .edit()112 .putBoolean(ONBOARD_KEY, true)113 .apply()114 onFinished()115 }116117 ShellBackground {118 RuneGridBackground(modifier = Modifier.fillMaxSize())119120 121 Text(122 text = "peach",123 color = accent,124 style = PeachType.wordmark,125 modifier = Modifier126 .align(Alignment.TopStart)127 .systemBarsPadding()128 .padding(start = 20.dp, top = 8.dp),129 )130131 Column(132 modifier = Modifier133 .fillMaxSize()134 .systemBarsPadding()135 .padding(horizontal = 28.dp),136 verticalArrangement = Arrangement.Center,137 horizontalAlignment = Alignment.CenterHorizontally,138 ) {139 CardSwitcher(card) { active ->140 GlassCardPanel {141 CardContent(ONBOARDING_CARDS[active])142 }143 }144145 Spacer(Modifier.height(28.dp))146147 NavRow(148 card = card,149 cardCount = cardCount,150 onBack = { if (card > 0) card-- },151 onNext = { if (card < cardCount - 1) card++ else finish() },152 )153 }154 }155}156157158159160161162163164165166167@Composable168private fun CardSwitcher(169 target: Int,170 content: @Composable (Int) -> Unit,171) {172 val density = LocalDensity.current173 var shown by remember { mutableIntStateOf(target) }174 val translate = remember { Animatable(0f) }175 val fade = remember { Animatable(1f) }176177 LaunchedEffect(target) {178 if (target == shown) return@LaunchedEffect179 180 val exitPx = with(density) { (-12).dp.toPx() }181 translate.animateTo(exitPx, tween(120))182 fade.animateTo(0f, tween(120))183 184 shown = target185 translate.snapTo(with(density) { 12.dp.toPx() })186 187 translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring))188 }189 LaunchedEffect(shown) {190 if (fade.value < 1f) fade.animateTo(1f, tween(200))191 }192193 Box(194 modifier = Modifier195 .fillMaxWidth()196 .offset { IntOffset(translate.value.toInt(), 0) }197 .alpha(fade.value),198 ) {199 content(shown)200 }201}202203204205@Composable206private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {207 Column(208 modifier = Modifier209 .fillMaxWidth()210 .clip(RoundedCornerShape(18.dp))211 .background(peachColors.SurfaceElevated)212 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))213 .padding(horizontal = 24.dp, vertical = 28.dp),214 content = content,215 )216}217218219220private fun headline() = TextStyle(221 fontFamily = PeachType.Display,222 fontStyle = FontStyle.Italic,223 fontSize = 40.sp,224 lineHeight = (40 * 0.98).sp,225)226227@Composable228private fun CardContent(copy: OnboardingCardCopy) {229 Text(copy.headline, color = peachColors.T0, style = headline())230 Spacer(Modifier.height(18.dp))231 copy.body.forEachIndexed { i, para ->232 if (i > 0) Spacer(Modifier.height(12.dp))233 Text(234 para,235 color = peachColors.T1,236 style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),237 )238 }239 copy.warning?.let { WarningRow(it) }240}241242@Composable243private fun WarningRow(text: String) {244 val accent = LocalPeachAccent.current245 Spacer(Modifier.height(20.dp))246 Column(247 modifier = Modifier248 .fillMaxWidth()249 .drawBehind {250 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)251 drawLine(252 color = accent,253 start = Offset(0f, 0f),254 end = Offset(size.width, 0f),255 strokeWidth = 1.dp.toPx(),256 pathEffect = dash,257 )258 }259 .padding(top = 12.dp),260 ) {261 Row(verticalAlignment = Alignment.Top) {262 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))263 Spacer(Modifier.width(8.dp))264 Text(265 text,266 color = peachColors.T1,267 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),268 )269 }270 }271}272273274275276internal fun onboardingBackVisible(card: Int): Boolean = card > 0277278279internal fun onboardingNextLabel(card: Int, count: Int): String =280 if (card == count - 1) "Get started" else "next"281282@Composable283private fun NavRow(284 card: Int,285 cardCount: Int,286 onBack: () -> Unit,287 onNext: () -> Unit,288) {289 val accent = LocalPeachAccent.current290 Row(291 modifier = Modifier.fillMaxWidth(),292 verticalAlignment = Alignment.CenterVertically,293 ) {294 295 Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.CenterStart) {296 if (onboardingBackVisible(card)) {297 Text(298 "back",299 color = peachColors.T2,300 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),301 modifier = Modifier302 .clip(RoundedCornerShape(6.dp))303 .clickable(onClick = onBack)304 .padding(vertical = 6.dp, horizontal = 4.dp),305 )306 }307 }308309 310 Row(311 modifier = Modifier.weight(1f),312 horizontalArrangement = Arrangement.Center,313 verticalAlignment = Alignment.CenterVertically,314 ) {315 repeat(cardCount) { i ->316 val activeDot = i == card317 Box(318 modifier = Modifier319 .padding(horizontal = 4.dp)320 .size(if (activeDot) 8.dp else 6.dp)321 .clip(RoundedCornerShape(999.dp))322 .background(if (activeDot) accent else peachColors.T2),323 )324 }325 }326327 328 Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {329 Text(330 onboardingNextLabel(card, cardCount),331 color = peachColors.T1,332 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),333 modifier = Modifier334 .clip(RoundedCornerShape(6.dp))335 .clickable(onClick = onNext)336 .padding(vertical = 6.dp, horizontal = 4.dp),337 )338 }339 }340}341342343344const val ONBOARD_PREFS = "peach_prefs"345const val ONBOARD_KEY = "peach_onboarded"346347348349350351352353fun onboardingStartDestination(354 hasVault: Boolean,355 onboarded: Boolean,356): com.peachpasswords.ui.PeachNav =357 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding358 else com.peachpasswords.ui.PeachNav.Lock