1package com.peachpasswords.ui.onboarding23import androidx.compose.animation.core.Animatable4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.animateFloat6import androidx.compose.animation.core.infiniteRepeatable7import androidx.compose.animation.core.rememberInfiniteTransition8import androidx.compose.animation.core.tween9import androidx.compose.foundation.Canvas10import androidx.compose.foundation.background11import androidx.compose.foundation.border12import androidx.compose.foundation.clickable13import androidx.compose.foundation.layout.Arrangement14import androidx.compose.foundation.layout.Box15import androidx.compose.foundation.layout.Column16import androidx.compose.foundation.layout.Row17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxSize19import androidx.compose.foundation.layout.fillMaxWidth20import androidx.compose.foundation.layout.height21import androidx.compose.foundation.layout.offset22import androidx.compose.foundation.layout.padding23import androidx.compose.foundation.layout.size24import androidx.compose.foundation.layout.systemBarsPadding25import androidx.compose.foundation.layout.width26import androidx.compose.foundation.shape.RoundedCornerShape27import androidx.compose.runtime.Composable28import androidx.compose.runtime.LaunchedEffect29import androidx.compose.runtime.getValue30import androidx.compose.runtime.mutableIntStateOf31import androidx.compose.runtime.remember32import 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.Color40import androidx.compose.ui.graphics.PathEffect41import androidx.compose.ui.platform.LocalContext42import androidx.compose.ui.platform.LocalDensity43import androidx.compose.ui.text.TextStyle44import androidx.compose.ui.text.drawText45import androidx.compose.ui.text.font.FontStyle46import androidx.compose.ui.text.rememberTextMeasurer47import androidx.compose.ui.unit.IntOffset48import androidx.compose.ui.unit.dp49import androidx.compose.ui.unit.em50import androidx.compose.ui.unit.sp51import androidx.compose.material3.Text52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.LocalReduceMotion54import com.peachpasswords.ui.theme.peachColors55import com.peachpasswords.ui.theme.PeachType56import com.peachpasswords.ui.theme.ShellBackground57import kotlin.random.Random5859606162636465data class OnboardingCardCopy(66 val headline: String,67 val body: List<String>,68 val warning: String?,69)707172val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(73 OnboardingCardCopy(74 headline = "your passwords\nlive here.\nnowhere else.",75 body = listOf(76 "No account. No server. No one but you can ever see your vault.",77 "Everything is encrypted on your device with a password only you know.",78 ),79 warning = null,80 ),81 OnboardingCardCopy(82 headline = "two keys.",83 body = listOf(84 "Your master password is yours alone. Peach never sees it.",85 "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",86 ),87 warning = null,88 ),89 OnboardingCardCopy(90 headline = "everything\nyou need.",91 body = listOf(92 "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",93 "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",94 ),95 warning = "Lose both your password and your recovery words and your vault cannot be opened.",96 ),97)9899100101102103104105106107@Composable108fun OnboardingScreen(109 onFinished: () -> Unit,110) {111 val context = LocalContext.current112 var card by remember { mutableIntStateOf(0) }113 val cardCount = ONBOARDING_CARDS.size114 val accent = LocalPeachAccent.current115116 val finish: () -> Unit = {117 context118 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)119 .edit()120 .putBoolean(ONBOARD_KEY, true)121 .apply()122 onFinished()123 }124125 ShellBackground {126 127 PeachScriptRainCanvas(modifier = Modifier.fillMaxSize(), accent = accent)128129 130 Text(131 text = "peach",132 color = accent,133 style = PeachType.wordmark,134 modifier = Modifier135 .align(Alignment.TopStart)136 .systemBarsPadding()137 .padding(start = 20.dp, top = 8.dp),138 )139140 Column(141 modifier = Modifier142 .fillMaxSize()143 .systemBarsPadding()144 .padding(horizontal = 28.dp),145 verticalArrangement = Arrangement.Center,146 horizontalAlignment = Alignment.CenterHorizontally,147 ) {148 CardSwitcher(card) { active ->149 GlassCardPanel {150 CardContent(ONBOARDING_CARDS[active])151 }152 }153154 Spacer(Modifier.height(28.dp))155156 NavRow(157 card = card,158 cardCount = cardCount,159 onBack = { if (card > 0) card-- },160 onNext = { if (card < cardCount - 1) card++ else finish() },161 )162 }163 }164}165166167168private const val RAIN_COLUMNS = 26169170private class RainColumn(171 val glyph: String,172 val xFraction: Float,173 val speed: Float,174 val startOffset: Float,175)176177178179180181182183@Composable184fun PeachScriptRainCanvas(modifier: Modifier = Modifier, accent: Color) {185 val reduceMotion = LocalReduceMotion.current186 val measurer = rememberTextMeasurer()187 val columns = remember {188 List(RAIN_COLUMNS) {189 RainColumn(190 glyph = (0xE000 + Random.nextInt(256)).toChar().toString(),191 xFraction = Random.nextFloat(),192 193 speed = 0.5f + Random.nextFloat() * 0.5f,194 startOffset = Random.nextFloat(),195 )196 }197 }198 val glyphStyle = remember(accent) {199 TextStyle(fontFamily = PeachType.Script, fontSize = 30.sp, color = accent.copy(alpha = 0.09f))200 }201 val measured = remember(columns, glyphStyle, measurer) {202 columns.map { measurer.measure(it.glyph, glyphStyle) }203 }204205 val phase: Float = if (reduceMotion) {206 0f207 } else {208 val transition = rememberInfiniteTransition(label = "peachscript_rain")209 val animated by transition.animateFloat(210 initialValue = 0f,211 targetValue = 1f,212 animationSpec = infiniteRepeatable(tween(80_000, easing = LinearEasing)),213 label = "rain_phase",214 )215 animated216 }217218 Canvas(modifier) {219 val glyphPx = 30.sp.toPx()220 val travel = size.height + glyphPx * 2221 columns.forEachIndexed { i, col ->222 val x = col.xFraction * size.width223 val progress = ((phase * col.speed) + col.startOffset) % 1f224 val y = progress * travel - glyphPx225 drawText(measured[i], topLeft = Offset(x, y))226 }227 }228}229230231232233234235236237238239240@Composable241private fun CardSwitcher(242 target: Int,243 content: @Composable (Int) -> Unit,244) {245 val density = LocalDensity.current246 var shown by remember { mutableIntStateOf(target) }247 val translate = remember { Animatable(0f) }248 val fade = remember { Animatable(1f) }249250 LaunchedEffect(target) {251 if (target == shown) return@LaunchedEffect252 253 val exitPx = with(density) { (-12).dp.toPx() }254 translate.animateTo(exitPx, tween(120))255 fade.animateTo(0f, tween(120))256 257 shown = target258 translate.snapTo(with(density) { 12.dp.toPx() })259 260 translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring))261 }262 LaunchedEffect(shown) {263 if (fade.value < 1f) fade.animateTo(1f, tween(200))264 }265266 Box(267 modifier = Modifier268 .fillMaxWidth()269 .offset { IntOffset(translate.value.toInt(), 0) }270 .alpha(fade.value),271 ) {272 content(shown)273 }274}275276277278@Composable279private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {280 Column(281 modifier = Modifier282 .fillMaxWidth()283 .clip(RoundedCornerShape(18.dp))284 .background(peachColors.SurfaceElevated)285 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))286 .padding(horizontal = 24.dp, vertical = 28.dp),287 content = content,288 )289}290291292293private fun headline() = TextStyle(294 fontFamily = PeachType.Display,295 fontStyle = FontStyle.Italic,296 fontSize = 40.sp,297 lineHeight = (40 * 0.98).sp,298)299300@Composable301private fun CardContent(copy: OnboardingCardCopy) {302 Text(copy.headline, color = peachColors.T0, style = headline())303 Spacer(Modifier.height(18.dp))304 copy.body.forEachIndexed { i, para ->305 if (i > 0) Spacer(Modifier.height(12.dp))306 Text(307 para,308 color = peachColors.T1,309 style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),310 )311 }312 copy.warning?.let { WarningRow(it) }313}314315@Composable316private fun WarningRow(text: String) {317 val accent = LocalPeachAccent.current318 Spacer(Modifier.height(20.dp))319 Column(320 modifier = Modifier321 .fillMaxWidth()322 .drawBehind {323 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)324 drawLine(325 color = accent,326 start = Offset(0f, 0f),327 end = Offset(size.width, 0f),328 strokeWidth = 1.dp.toPx(),329 pathEffect = dash,330 )331 }332 .padding(top = 12.dp),333 ) {334 Row(verticalAlignment = Alignment.Top) {335 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))336 Spacer(Modifier.width(8.dp))337 Text(338 text,339 color = peachColors.T1,340 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),341 )342 }343 }344}345346347348349internal fun onboardingBackVisible(card: Int): Boolean = card > 0350351352internal fun onboardingNextLabel(card: Int, count: Int): String =353 if (card == count - 1) "Get started" 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 368 Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.CenterStart) {369 if (onboardingBackVisible(card)) {370 Text(371 "back",372 color = peachColors.T2,373 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),374 modifier = Modifier375 .clip(RoundedCornerShape(6.dp))376 .clickable(onClick = onBack)377 .padding(vertical = 6.dp, horizontal = 4.dp),378 )379 }380 }381382 383 Row(384 modifier = Modifier.weight(1f),385 horizontalArrangement = Arrangement.Center,386 verticalAlignment = Alignment.CenterVertically,387 ) {388 repeat(cardCount) { i ->389 val activeDot = i == card390 Box(391 modifier = Modifier392 .padding(horizontal = 4.dp)393 .size(if (activeDot) 8.dp else 6.dp)394 .clip(RoundedCornerShape(999.dp))395 .background(if (activeDot) accent else peachColors.T2),396 )397 }398 }399400 401 Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {402 Text(403 onboardingNextLabel(card, cardCount),404 color = peachColors.T1,405 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),406 modifier = Modifier407 .clip(RoundedCornerShape(6.dp))408 .clickable(onClick = onNext)409 .padding(vertical = 6.dp, horizontal = 4.dp),410 )411 }412 }413}414415416417const val ONBOARD_PREFS = "peach_prefs"418const val ONBOARD_KEY = "peach_onboarded"419420421422423424425426fun onboardingStartDestination(427 hasVault: Boolean,428 onboarded: Boolean,429): com.peachpasswords.ui.PeachNav =430 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding431 else com.peachpasswords.ui.PeachNav.Lock