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.interaction.MutableInteractionSource10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxHeight16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.offset20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.systemBarsPadding23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.layout.widthIn25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.runtime.Composable27import androidx.compose.runtime.LaunchedEffect28import androidx.compose.runtime.getValue29import androidx.compose.runtime.mutableIntStateOf30import androidx.compose.runtime.remember31import androidx.compose.runtime.saveable.rememberSaveable32import 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.PathEffect40import androidx.compose.ui.platform.LocalContext41import androidx.compose.ui.platform.LocalDensity42import androidx.compose.ui.text.TextStyle43import androidx.compose.ui.text.font.FontStyle44import androidx.compose.ui.unit.IntOffset45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.em47import androidx.compose.ui.unit.sp48import androidx.compose.material3.Text49import com.peachpasswords.ui.components.RuneGridBackground50import com.peachpasswords.ui.theme.LocalPeachAccent51import com.peachpasswords.ui.theme.peachColors52import com.peachpasswords.ui.theme.PeachType53import com.peachpasswords.ui.theme.ShellBackground54import kotlinx.coroutines.coroutineScope55import kotlinx.coroutines.delay56import kotlinx.coroutines.launch5758596061626364data class OnboardingCardCopy(65 val headline: String,66 val body: List<String>,67 val warning: String?,68)697071727374757677val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(78 OnboardingCardCopy(79 headline = "your passwords\nlive here.\nnowhere else.",80 body = listOf(81 "No account. No server. No one but you can ever see your vault.",82 "Everything is encrypted on your device with a password only you know.",83 ),84 warning = null,85 ),86 OnboardingCardCopy(87 headline = "two keys.",88 body = listOf(89 "Your master password is yours alone. Peach never sees it.",90 "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",91 ),92 warning = "Lose both and your vault cannot be opened.",93 ),94 OnboardingCardCopy(95 headline = "everything\nyou need.",96 body = listOf(97 "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",98 "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",99 ),100 warning = null,101 ),102)103104105106107108109110111112@Composable113fun OnboardingScreen(114 onFinished: () -> Unit,115 116 117 118 119 onRestore: () -> Unit = {},120) {121 val context = LocalContext.current122 123 124 125 var card by rememberSaveable { mutableIntStateOf(0) }126 val cardCount = ONBOARDING_CARDS.size127 val accent = LocalPeachAccent.current128129 val finish: () -> Unit = {130 context131 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)132 .edit()133 .putBoolean(ONBOARD_KEY, true)134 .apply()135 onFinished()136 }137 val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }138139 ShellBackground {140 RuneGridBackground(modifier = Modifier.fillMaxSize())141142 143 Text(144 text = "peach",145 color = accent,146 style = PeachType.wordmark,147 modifier = Modifier148 .align(Alignment.TopStart)149 .systemBarsPadding()150 .padding(start = 20.dp, top = 8.dp),151 )152153 Column(154 modifier = Modifier155 156 157 158 .align(Alignment.Center)159 .fillMaxHeight()160 .widthIn(max = 480.dp)161 .fillMaxWidth()162 .systemBarsPadding()163 .padding(horizontal = 28.dp),164 verticalArrangement = Arrangement.Center,165 horizontalAlignment = Alignment.CenterHorizontally,166 ) {167 CardSwitcher(card, onClick = advance) { active ->168 GlassCardPanel {169 CardContent(ONBOARDING_CARDS[active])170 171 172 if (active == cardCount - 1) {173 ChoiceRow(onRestore = onRestore)174 }175 }176 }177178 Spacer(Modifier.height(28.dp))179180 NavRow(181 card = card,182 cardCount = cardCount,183 onBack = { if (card > 0) card-- },184 onNext = advance,185 )186 }187 }188}189190191192193194195private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)196private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)197198199200201202203204205@Composable206private fun CardSwitcher(207 target: Int,208 onClick: () -> Unit,209 content: @Composable (Int) -> Unit,210) {211 val density = LocalDensity.current212 var shown by remember { mutableIntStateOf(target) }213 val translate = remember { Animatable(0f) }214 val fade = remember { Animatable(1f) }215216 LaunchedEffect(target) {217 if (target == shown) return@LaunchedEffect218 val exitPx = with(density) { (-12).dp.toPx() }219 coroutineScope {220 launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }221 launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }222 }223 delay(60)224 shown = target225 translate.snapTo(with(density) { 12.dp.toPx() })226 coroutineScope {227 launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }228 launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }229 }230 }231232 Box(233 modifier = Modifier234 .fillMaxWidth()235 .offset { IntOffset(translate.value.toInt(), 0) }236 .alpha(fade.value)237 238 239 .clickable(240 interactionSource = remember { MutableInteractionSource() },241 indication = null,242 onClick = onClick,243 ),244 ) {245 content(shown)246 }247}248249250251@Composable252private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {253 Column(254 modifier = Modifier255 .fillMaxWidth()256 .clip(RoundedCornerShape(18.dp))257 .background(peachColors.SurfaceElevated)258 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))259 .padding(horizontal = 24.dp, vertical = 28.dp),260 content = content,261 )262}263264265266private fun headline() = TextStyle(267 fontFamily = PeachType.Display,268 fontStyle = FontStyle.Italic,269 fontSize = 40.sp,270 lineHeight = (40 * 0.98).sp,271)272273@Composable274private fun CardContent(copy: OnboardingCardCopy) {275 Text(copy.headline, color = peachColors.T0, style = headline())276 Spacer(Modifier.height(18.dp))277 copy.body.forEachIndexed { i, para ->278 if (i > 0) Spacer(Modifier.height(12.dp))279 Text(280 para,281 color = peachColors.T1,282 style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),283 )284 }285 copy.warning?.let { WarningRow(it) }286}287288@Composable289private fun WarningRow(text: String) {290 val accent = LocalPeachAccent.current291 Spacer(Modifier.height(20.dp))292 Column(293 modifier = Modifier294 .fillMaxWidth()295 .drawBehind {296 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)297 drawLine(298 color = accent,299 start = Offset(0f, 0f),300 end = Offset(size.width, 0f),301 strokeWidth = 1.dp.toPx(),302 pathEffect = dash,303 )304 }305 .padding(top = 12.dp),306 ) {307 Row(verticalAlignment = Alignment.Top) {308 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))309 Spacer(Modifier.width(8.dp))310 Text(311 text,312 color = peachColors.T1,313 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),314 )315 }316 }317}318319320321322323324@Composable325private fun ChoiceRow(onRestore: () -> Unit) {326 Spacer(Modifier.height(14.dp))327 Text(328 "restore from backup",329 color = peachColors.T0,330 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),331 modifier = Modifier332 .fillMaxWidth()333 .clip(RoundedCornerShape(8.dp))334 .background(peachColors.T0.copy(alpha = 0.07f))335 .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))336 .clickable(337 interactionSource = remember { MutableInteractionSource() },338 indication = null,339 onClick = onRestore,340 )341 .padding(vertical = 8.dp, horizontal = 10.dp),342 )343}344345346347348internal fun onboardingBackVisible(card: Int): Boolean = card > 0349350351internal fun onboardingNextLabel(card: Int, count: Int): String =352 if (card == count - 1) "create vault" else "next →"353354@Composable355private fun NavRow(356 card: Int,357 cardCount: Int,358 onBack: () -> Unit,359 onNext: () -> Unit,360) {361 val accent = LocalPeachAccent.current362 Row(363 modifier = Modifier.fillMaxWidth(),364 verticalAlignment = Alignment.CenterVertically,365 ) {366 367 368 369 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {370 if (onboardingBackVisible(card)) {371 Text(372 "← back",373 color = peachColors.T2,374 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),375 modifier = Modifier376 .clip(RoundedCornerShape(6.dp))377 .clickable(onClick = onBack)378 .padding(vertical = 6.dp, horizontal = 4.dp),379 )380 }381 }382383 384 Row(385 modifier = Modifier.weight(1f),386 horizontalArrangement = Arrangement.Center,387 verticalAlignment = Alignment.CenterVertically,388 ) {389 repeat(cardCount) { i ->390 val activeDot = i == card391 Box(392 modifier = Modifier393 .padding(horizontal = 4.dp)394 .size(if (activeDot) 8.dp else 6.dp)395 .clip(RoundedCornerShape(999.dp))396 .background(if (activeDot) accent else peachColors.T2),397 )398 }399 }400401 402 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {403 Text(404 onboardingNextLabel(card, cardCount),405 color = peachColors.T1,406 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),407 maxLines = 1,408 modifier = Modifier409 .clip(RoundedCornerShape(6.dp))410 .clickable(onClick = onNext)411 .padding(vertical = 6.dp, horizontal = 4.dp),412 )413 }414 }415}416417418419const val ONBOARD_PREFS = "peach_prefs"420const val ONBOARD_KEY = "peach_onboarded"421422423424425426427428fun onboardingStartDestination(429 hasVault: Boolean,430 onboarded: Boolean,431): com.peachpasswords.ui.PeachNav =432 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding433 else com.peachpasswords.ui.PeachNav.Lock