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.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.font.FontStyle45import androidx.compose.ui.unit.IntOffset46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.em48import androidx.compose.ui.unit.sp49import androidx.compose.material3.Text50import com.peachpasswords.ui.components.PeachWordmark51import com.peachpasswords.ui.components.RuneGridBackground52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.peachColors54import com.peachpasswords.ui.theme.PeachType55import com.peachpasswords.ui.theme.ShellBackground56import kotlinx.coroutines.coroutineScope57import kotlinx.coroutines.delay58import kotlinx.coroutines.launch5960616263646566data class OnboardingCardCopy(67 val headline: String,68 val body: List<String>,69 val warning: String?,70)717273747576777879val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(80 OnboardingCardCopy(81 headline = "your passwords\nlive here.\nnowhere else.",82 body = listOf(83 "No account. No server. No one but you can ever see your vault.",84 "Everything is encrypted on your device with a password only you know.",85 ),86 warning = null,87 ),88 OnboardingCardCopy(89 headline = "two keys.",90 body = listOf(91 "Your master password is yours alone. Peach never sees it.",92 "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",93 ),94 warning = "Lose both and your vault cannot be opened.",95 ),96 OnboardingCardCopy(97 headline = "everything\nyou need.",98 body = listOf(99 "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",100 "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",101 ),102 warning = null,103 ),104)105106107108109110111112113114@Composable115fun OnboardingScreen(116 onFinished: () -> Unit,117 onRestore: () -> Unit = {},118 onAdopt: () -> Unit = {},119) {120 val context = LocalContext.current121 122 123 124 var card by rememberSaveable { mutableIntStateOf(0) }125 val cardCount = ONBOARDING_CARDS.size126 val accent = LocalPeachAccent.current127128 val finish: () -> Unit = {129 context130 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)131 .edit()132 .putBoolean(ONBOARD_KEY, true)133 .apply()134 onFinished()135 }136 val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }137138 ShellBackground {139 RuneGridBackground(modifier = Modifier.fillMaxSize())140141 PeachWordmark(142 modifier = Modifier143 .align(Alignment.TopStart)144 .systemBarsPadding()145 .padding(start = 20.dp, top = 8.dp),146 )147148 Column(149 modifier = Modifier150 .align(Alignment.Center)151 .fillMaxHeight()152 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)153 .fillMaxWidth()154 .systemBarsPadding()155 .padding(horizontal = 28.dp),156 horizontalAlignment = Alignment.CenterHorizontally,157 ) {158 159 160 161 Spacer(Modifier.height(40.dp))162 CardSwitcher(card, onClick = { if (card < cardCount - 1) advance() }) { active ->163 GlassCardPanel {164 CardContent(ONBOARDING_CARDS[active])165 166 167 if (active == cardCount - 1) {168 ChoiceRow(onRestore = onRestore, onAdopt = onAdopt, onCreate = advance)169 }170 }171 }172173 Spacer(Modifier.weight(1f))174175 NavRow(176 card = card,177 cardCount = cardCount,178 onBack = { if (card > 0) card-- },179 onNext = advance,180 )181 Spacer(Modifier.height(12.dp))182 }183 }184}185186187188189190191private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)192private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)193194195196197198199200201@Composable202private fun CardSwitcher(203 target: Int,204 onClick: () -> Unit,205 modifier: Modifier = Modifier,206 content: @Composable (Int) -> Unit,207) {208 val density = LocalDensity.current209 var shown by remember { mutableIntStateOf(target) }210 val translate = remember { Animatable(0f) }211 val fade = remember { Animatable(1f) }212213 LaunchedEffect(target) {214 if (target == shown) return@LaunchedEffect215 val exitPx = with(density) { (-12).dp.toPx() }216 coroutineScope {217 launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }218 launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }219 }220 delay(60)221 shown = target222 translate.snapTo(with(density) { 12.dp.toPx() })223 coroutineScope {224 launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }225 launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }226 }227 }228229 Box(230 modifier = modifier231 .fillMaxWidth()232 .offset { IntOffset(translate.value.toInt(), 0) }233 .alpha(fade.value)234 235 236 .clickable(237 interactionSource = remember { MutableInteractionSource() },238 indication = null,239 onClick = onClick,240 ),241 ) {242 content(shown)243 }244}245246247248@Composable249private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {250 Column(251 modifier = Modifier252 .fillMaxWidth()253 .clip(RoundedCornerShape(18.dp))254 .background(peachColors.SurfaceElevated)255 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))256 .padding(horizontal = 26.dp, vertical = 44.dp),257 content = content,258 )259}260261262263private fun headline() = TextStyle(264 fontFamily = PeachType.Display,265 fontStyle = FontStyle.Italic,266 fontSize = 44.sp,267 lineHeight = (44 * 1.0).sp,268)269270@Composable271private fun CardContent(copy: OnboardingCardCopy) {272 Text(copy.headline, color = peachColors.T0, style = headline())273 Spacer(Modifier.height(24.dp))274 copy.body.forEachIndexed { i, para ->275 if (i > 0) Spacer(Modifier.height(18.dp))276 Text(277 para,278 color = peachColors.T1,279 style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),280 )281 }282 copy.warning?.let { WarningRow(it) }283}284285@Composable286private fun WarningRow(text: String) {287 val accent = LocalPeachAccent.current288 Spacer(Modifier.height(20.dp))289 Column(290 modifier = Modifier291 .fillMaxWidth()292 .drawBehind {293 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)294 drawLine(295 color = accent,296 start = Offset(0f, 0f),297 end = Offset(size.width, 0f),298 strokeWidth = 1.dp.toPx(),299 pathEffect = dash,300 )301 }302 .padding(top = 12.dp),303 ) {304 Row(verticalAlignment = Alignment.Top) {305 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))306 Spacer(Modifier.width(8.dp))307 Text(308 text,309 color = peachColors.T1,310 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),311 )312 }313 }314}315316317318319320321internal const val ONBOARD_CREATE_LABEL = "Create a new vault"322internal const val ONBOARD_PAIR_LABEL = "Pair with another device"323internal const val ONBOARD_RESTORE_LABEL = "Restore a backup"324325@Composable326private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit, onCreate: () -> Unit) {327 Spacer(Modifier.height(18.dp))328 ChoiceButton(329 ONBOARD_CREATE_LABEL,330 "Set your master password and start saving.",331 onCreate,332 primary = true,333 )334 Spacer(Modifier.height(8.dp))335 Text(336 "Already using Peach?",337 color = peachColors.T1,338 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),339 )340 Spacer(Modifier.height(8.dp))341 ChoiceButton(342 ONBOARD_PAIR_LABEL,343 "Scan a QR and confirm one 8-digit code.",344 onAdopt,345 pro = true,346 )347 Spacer(Modifier.height(8.dp))348 ChoiceButton(349 ONBOARD_RESTORE_LABEL,350 "Use a Peach backup or Codex.",351 onRestore,352 )353}354355@Composable356private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit, primary: Boolean = false, pro: Boolean = false) {357 val accent = LocalPeachAccent.current358 val labelColor = if (primary) Color(0f, 0f, 0f, 0.92f) else peachColors.T0359 val detailColor = if (primary) Color(0f, 0f, 0f, 0.72f) else peachColors.T1360 Column(361 modifier = Modifier362 .fillMaxWidth()363 .clip(RoundedCornerShape(8.dp))364 .background(if (primary) accent else peachColors.T0.copy(alpha = 0.07f))365 .border(1.dp, if (primary) accent else peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))366 .clickable(367 interactionSource = remember { MutableInteractionSource() },368 indication = null,369 onClick = onClick,370 )371 .padding(vertical = 9.dp, horizontal = 10.dp),372 ) {373 Row(verticalAlignment = Alignment.CenterVertically) {374 Text(375 label,376 color = labelColor,377 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),378 )379 if (pro) {380 Spacer(Modifier.width(6.dp))381 Box(382 Modifier383 .clip(RoundedCornerShape(4.dp))384 .background(accent.copy(alpha = 0.16f))385 .padding(horizontal = 5.dp, vertical = 1.dp),386 ) {387 Text(388 "Pro",389 color = accent.copy(alpha = 0.85f),390 fontFamily = PeachType.Mono,391 fontSize = 8.sp,392 )393 }394 }395 }396 Spacer(Modifier.height(2.dp))397 Text(398 detail,399 color = detailColor,400 style = PeachType.labelMono.copy(fontSize = 9.sp),401 )402 }403}404405406407408internal fun onboardingBackVisible(card: Int): Boolean = card > 0409410411internal fun onboardingNextLabel(card: Int, count: Int): String =412 if (card == count - 1) "create vault" else "next →"413414@Composable415private fun NavRow(416 card: Int,417 cardCount: Int,418 onBack: () -> Unit,419 onNext: () -> Unit,420) {421 val accent = LocalPeachAccent.current422 Row(423 modifier = Modifier.fillMaxWidth(),424 verticalAlignment = Alignment.CenterVertically,425 ) {426 427 428 429 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {430 if (onboardingBackVisible(card)) {431 Text(432 "← back",433 color = peachColors.T2,434 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),435 modifier = Modifier436 .clip(RoundedCornerShape(6.dp))437 .clickable(onClick = onBack)438 .padding(vertical = 6.dp, horizontal = 4.dp),439 )440 }441 }442443 444 Row(445 modifier = Modifier.weight(1f),446 horizontalArrangement = Arrangement.Center,447 verticalAlignment = Alignment.CenterVertically,448 ) {449 repeat(cardCount) { i ->450 val activeDot = i == card451 Box(452 modifier = Modifier453 .padding(horizontal = 4.dp)454 .size(if (activeDot) 8.dp else 6.dp)455 .clip(RoundedCornerShape(999.dp))456 .background(if (activeDot) accent else peachColors.T2),457 )458 }459 }460461 462 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {463 Text(464 onboardingNextLabel(card, cardCount),465 color = peachColors.T1,466 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),467 maxLines = 1,468 modifier = Modifier469 .clip(RoundedCornerShape(6.dp))470 .clickable(onClick = onNext)471 .padding(vertical = 6.dp, horizontal = 4.dp),472 )473 }474 }475}476477478479const val ONBOARD_PREFS = "peach_prefs"480const val ONBOARD_KEY = "peach_onboarded"481482483484485486487488fun onboardingStartDestination(489 hasVault: Boolean,490 onboarded: Boolean,491): com.peachpasswords.ui.PeachNav =492 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding493 else com.peachpasswords.ui.PeachNav.Lock