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.PeachWordmark50import com.peachpasswords.ui.components.RuneGridBackground51import com.peachpasswords.ui.theme.LocalPeachAccent52import com.peachpasswords.ui.theme.peachColors53import com.peachpasswords.ui.theme.PeachType54import com.peachpasswords.ui.theme.ShellBackground55import kotlinx.coroutines.coroutineScope56import kotlinx.coroutines.delay57import kotlinx.coroutines.launch5859606162636465data class OnboardingCardCopy(66 val headline: String,67 val body: List<String>,68 val warning: String?,69)707172737475767778val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(79 OnboardingCardCopy(80 headline = "your passwords\nlive here.\nnowhere else.",81 body = listOf(82 "No account. No server. No one but you can ever see your vault.",83 "Everything is encrypted on your device with a password only you know.",84 ),85 warning = null,86 ),87 OnboardingCardCopy(88 headline = "two keys.",89 body = listOf(90 "Your master password is yours alone. Peach never sees it.",91 "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",92 ),93 warning = "Lose both and your vault cannot be opened.",94 ),95 OnboardingCardCopy(96 headline = "everything\nyou need.",97 body = listOf(98 "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",99 "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",100 ),101 warning = null,102 ),103)104105106107108109110111112113@Composable114fun OnboardingScreen(115 onFinished: () -> Unit,116 117 118 119 120 onRestore: () -> Unit = {},121) {122 val context = LocalContext.current123 124 125 126 var card by rememberSaveable { mutableIntStateOf(0) }127 val cardCount = ONBOARDING_CARDS.size128 val accent = LocalPeachAccent.current129130 val finish: () -> Unit = {131 context132 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)133 .edit()134 .putBoolean(ONBOARD_KEY, true)135 .apply()136 onFinished()137 }138 val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }139140 ShellBackground {141 RuneGridBackground(modifier = Modifier.fillMaxSize())142143 PeachWordmark(144 modifier = Modifier145 .align(Alignment.TopStart)146 .systemBarsPadding()147 .padding(start = 20.dp, top = 8.dp),148 )149150 Column(151 modifier = Modifier152 .align(Alignment.Center)153 .fillMaxHeight()154 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)155 .fillMaxWidth()156 .systemBarsPadding()157 .padding(horizontal = 28.dp),158 horizontalAlignment = Alignment.CenterHorizontally,159 ) {160 161 162 163 Spacer(Modifier.height(40.dp))164 CardSwitcher(card, onClick = advance) { active ->165 GlassCardPanel {166 CardContent(ONBOARDING_CARDS[active])167 168 169 if (active == cardCount - 1) {170 ChoiceRow(onRestore = onRestore)171 }172 }173 }174175 Spacer(Modifier.weight(1f))176177 NavRow(178 card = card,179 cardCount = cardCount,180 onBack = { if (card > 0) card-- },181 onNext = advance,182 )183 Spacer(Modifier.height(12.dp))184 }185 }186}187188189190191192193private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)194private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)195196197198199200201202203@Composable204private fun CardSwitcher(205 target: Int,206 onClick: () -> Unit,207 modifier: Modifier = Modifier,208 content: @Composable (Int) -> Unit,209) {210 val density = LocalDensity.current211 var shown by remember { mutableIntStateOf(target) }212 val translate = remember { Animatable(0f) }213 val fade = remember { Animatable(1f) }214215 LaunchedEffect(target) {216 if (target == shown) return@LaunchedEffect217 val exitPx = with(density) { (-12).dp.toPx() }218 coroutineScope {219 launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }220 launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }221 }222 delay(60)223 shown = target224 translate.snapTo(with(density) { 12.dp.toPx() })225 coroutineScope {226 launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }227 launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }228 }229 }230231 Box(232 modifier = modifier233 .fillMaxWidth()234 .offset { IntOffset(translate.value.toInt(), 0) }235 .alpha(fade.value)236 237 238 .clickable(239 interactionSource = remember { MutableInteractionSource() },240 indication = null,241 onClick = onClick,242 ),243 ) {244 content(shown)245 }246}247248249250@Composable251private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {252 Column(253 modifier = Modifier254 .fillMaxWidth()255 .clip(RoundedCornerShape(18.dp))256 .background(peachColors.SurfaceElevated)257 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))258 .padding(horizontal = 26.dp, vertical = 44.dp),259 content = content,260 )261}262263264265private fun headline() = TextStyle(266 fontFamily = PeachType.Display,267 fontStyle = FontStyle.Italic,268 fontSize = 44.sp,269 lineHeight = (44 * 1.0).sp,270)271272@Composable273private fun CardContent(copy: OnboardingCardCopy) {274 Text(copy.headline, color = peachColors.T0, style = headline())275 Spacer(Modifier.height(24.dp))276 copy.body.forEachIndexed { i, para ->277 if (i > 0) Spacer(Modifier.height(18.dp))278 Text(279 para,280 color = peachColors.T1,281 style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),282 )283 }284 copy.warning?.let { WarningRow(it) }285}286287@Composable288private fun WarningRow(text: String) {289 val accent = LocalPeachAccent.current290 Spacer(Modifier.height(20.dp))291 Column(292 modifier = Modifier293 .fillMaxWidth()294 .drawBehind {295 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)296 drawLine(297 color = accent,298 start = Offset(0f, 0f),299 end = Offset(size.width, 0f),300 strokeWidth = 1.dp.toPx(),301 pathEffect = dash,302 )303 }304 .padding(top = 12.dp),305 ) {306 Row(verticalAlignment = Alignment.Top) {307 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))308 Spacer(Modifier.width(8.dp))309 Text(310 text,311 color = peachColors.T1,312 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),313 )314 }315 }316}317318319320321322323@Composable324private fun ChoiceRow(onRestore: () -> Unit) {325 Spacer(Modifier.height(14.dp))326 Text(327 "restore from backup",328 color = peachColors.T0,329 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),330 modifier = Modifier331 .fillMaxWidth()332 .clip(RoundedCornerShape(8.dp))333 .background(peachColors.T0.copy(alpha = 0.07f))334 .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))335 .clickable(336 interactionSource = remember { MutableInteractionSource() },337 indication = null,338 onClick = onRestore,339 )340 .padding(vertical = 8.dp, horizontal = 10.dp),341 )342}343344345346347internal fun onboardingBackVisible(card: Int): Boolean = card > 0348349350internal fun onboardingNextLabel(card: Int, count: Int): String =351 if (card == count - 1) "create vault" else "next →"352353@Composable354private fun NavRow(355 card: Int,356 cardCount: Int,357 onBack: () -> Unit,358 onNext: () -> Unit,359) {360 val accent = LocalPeachAccent.current361 Row(362 modifier = Modifier.fillMaxWidth(),363 verticalAlignment = Alignment.CenterVertically,364 ) {365 366 367 368 Box(modifier = Modifier.widthIn(min = 72.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.widthIn(min = 72.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 maxLines = 1,407 modifier = Modifier408 .clip(RoundedCornerShape(6.dp))409 .clickable(onClick = onNext)410 .padding(vertical = 6.dp, horizontal = 4.dp),411 )412 }413 }414}415416417418const val ONBOARD_PREFS = "peach_prefs"419const val ONBOARD_KEY = "peach_onboarded"420421422423424425426427fun onboardingStartDestination(428 hasVault: Boolean,429 onboarded: Boolean,430): com.peachpasswords.ui.PeachNav =431 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding432 else com.peachpasswords.ui.PeachNav.Lock