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.ShellBackground5253545556575859data class OnboardingCardCopy(60 val headline: String,61 val body: List<String>,62 val warning: String?,63)646566val 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)9293949596979899100101@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 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 136 137 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}164165166167168169170171172173174175@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 188 val exitPx = with(density) { (-12).dp.toPx() }189 translate.animateTo(exitPx, tween(120))190 fade.animateTo(0f, tween(120))191 192 shown = target193 translate.snapTo(with(density) { 12.dp.toPx() })194 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}210211212213@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}225226227228private 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}280281282283284internal fun onboardingBackVisible(card: Int): Boolean = card > 0285286287internal 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 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 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 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}349350351352const val ONBOARD_PREFS = "peach_prefs"353const val ONBOARD_KEY = "peach_onboarded"354355356357358359360361fun 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