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 153 154 155 .align(Alignment.Center)156 .fillMaxHeight()157 .widthIn(max = 480.dp)158 .fillMaxWidth()159 .systemBarsPadding()160 .padding(horizontal = 28.dp),161 horizontalAlignment = Alignment.CenterHorizontally,162 ) {163 164 165 166 Spacer(Modifier.height(40.dp))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.weight(1f))179180 NavRow(181 card = card,182 cardCount = cardCount,183 onBack = { if (card > 0) card-- },184 onNext = advance,185 )186 Spacer(Modifier.height(12.dp))187 }188 }189}190191192193194195196private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)197private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)198199200201202203204205206@Composable207private fun CardSwitcher(208 target: Int,209 onClick: () -> Unit,210 modifier: Modifier = Modifier,211 content: @Composable (Int) -> Unit,212) {213 val density = LocalDensity.current214 var shown by remember { mutableIntStateOf(target) }215 val translate = remember { Animatable(0f) }216 val fade = remember { Animatable(1f) }217218 LaunchedEffect(target) {219 if (target == shown) return@LaunchedEffect220 val exitPx = with(density) { (-12).dp.toPx() }221 coroutineScope {222 launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }223 launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }224 }225 delay(60)226 shown = target227 translate.snapTo(with(density) { 12.dp.toPx() })228 coroutineScope {229 launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }230 launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }231 }232 }233234 Box(235 modifier = modifier236 .fillMaxWidth()237 .offset { IntOffset(translate.value.toInt(), 0) }238 .alpha(fade.value)239 240 241 .clickable(242 interactionSource = remember { MutableInteractionSource() },243 indication = null,244 onClick = onClick,245 ),246 ) {247 content(shown)248 }249}250251252253@Composable254private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {255 Column(256 modifier = Modifier257 .fillMaxWidth()258 .clip(RoundedCornerShape(18.dp))259 .background(peachColors.SurfaceElevated)260 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))261 .padding(horizontal = 26.dp, vertical = 44.dp),262 content = content,263 )264}265266267268private fun headline() = TextStyle(269 fontFamily = PeachType.Display,270 fontStyle = FontStyle.Italic,271 fontSize = 44.sp,272 lineHeight = (44 * 1.0).sp,273)274275@Composable276private fun CardContent(copy: OnboardingCardCopy) {277 Text(copy.headline, color = peachColors.T0, style = headline())278 Spacer(Modifier.height(24.dp))279 copy.body.forEachIndexed { i, para ->280 if (i > 0) Spacer(Modifier.height(18.dp))281 Text(282 para,283 color = peachColors.T1,284 style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),285 )286 }287 copy.warning?.let { WarningRow(it) }288}289290@Composable291private fun WarningRow(text: String) {292 val accent = LocalPeachAccent.current293 Spacer(Modifier.height(20.dp))294 Column(295 modifier = Modifier296 .fillMaxWidth()297 .drawBehind {298 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)299 drawLine(300 color = accent,301 start = Offset(0f, 0f),302 end = Offset(size.width, 0f),303 strokeWidth = 1.dp.toPx(),304 pathEffect = dash,305 )306 }307 .padding(top = 12.dp),308 ) {309 Row(verticalAlignment = Alignment.Top) {310 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))311 Spacer(Modifier.width(8.dp))312 Text(313 text,314 color = peachColors.T1,315 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),316 )317 }318 }319}320321322323324325326@Composable327private fun ChoiceRow(onRestore: () -> Unit) {328 Spacer(Modifier.height(14.dp))329 Text(330 "restore from backup",331 color = peachColors.T0,332 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),333 modifier = Modifier334 .fillMaxWidth()335 .clip(RoundedCornerShape(8.dp))336 .background(peachColors.T0.copy(alpha = 0.07f))337 .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))338 .clickable(339 interactionSource = remember { MutableInteractionSource() },340 indication = null,341 onClick = onRestore,342 )343 .padding(vertical = 8.dp, horizontal = 10.dp),344 )345}346347348349350internal fun onboardingBackVisible(card: Int): Boolean = card > 0351352353internal fun onboardingNextLabel(card: Int, count: Int): String =354 if (card == count - 1) "create vault" else "next →"355356@Composable357private fun NavRow(358 card: Int,359 cardCount: Int,360 onBack: () -> Unit,361 onNext: () -> Unit,362) {363 val accent = LocalPeachAccent.current364 Row(365 modifier = Modifier.fillMaxWidth(),366 verticalAlignment = Alignment.CenterVertically,367 ) {368 369 370 371 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {372 if (onboardingBackVisible(card)) {373 Text(374 "← back",375 color = peachColors.T2,376 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),377 modifier = Modifier378 .clip(RoundedCornerShape(6.dp))379 .clickable(onClick = onBack)380 .padding(vertical = 6.dp, horizontal = 4.dp),381 )382 }383 }384385 386 Row(387 modifier = Modifier.weight(1f),388 horizontalArrangement = Arrangement.Center,389 verticalAlignment = Alignment.CenterVertically,390 ) {391 repeat(cardCount) { i ->392 val activeDot = i == card393 Box(394 modifier = Modifier395 .padding(horizontal = 4.dp)396 .size(if (activeDot) 8.dp else 6.dp)397 .clip(RoundedCornerShape(999.dp))398 .background(if (activeDot) accent else peachColors.T2),399 )400 }401 }402403 404 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {405 Text(406 onboardingNextLabel(card, cardCount),407 color = peachColors.T1,408 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),409 maxLines = 1,410 modifier = Modifier411 .clip(RoundedCornerShape(6.dp))412 .clickable(onClick = onNext)413 .padding(vertical = 6.dp, horizontal = 4.dp),414 )415 }416 }417}418419420421const val ONBOARD_PREFS = "peach_prefs"422const val ONBOARD_KEY = "peach_onboarded"423424425426427428429430fun onboardingStartDestination(431 hasVault: Boolean,432 onboarded: Boolean,433): com.peachpasswords.ui.PeachNav =434 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding435 else com.peachpasswords.ui.PeachNav.Lock