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 onRestore: () -> Unit = {},117 onAdopt: () -> Unit = {},118) {119 val context = LocalContext.current120 121 122 123 var card by rememberSaveable { mutableIntStateOf(0) }124 val cardCount = ONBOARDING_CARDS.size125 val accent = LocalPeachAccent.current126127 val finish: () -> Unit = {128 context129 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)130 .edit()131 .putBoolean(ONBOARD_KEY, true)132 .apply()133 onFinished()134 }135 val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }136137 ShellBackground {138 RuneGridBackground(modifier = Modifier.fillMaxSize())139140 PeachWordmark(141 modifier = Modifier142 .align(Alignment.TopStart)143 .systemBarsPadding()144 .padding(start = 20.dp, top = 8.dp),145 )146147 Column(148 modifier = Modifier149 .align(Alignment.Center)150 .fillMaxHeight()151 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)152 .fillMaxWidth()153 .systemBarsPadding()154 .padding(horizontal = 28.dp),155 horizontalAlignment = Alignment.CenterHorizontally,156 ) {157 158 159 160 Spacer(Modifier.height(40.dp))161 CardSwitcher(card, onClick = advance) { active ->162 GlassCardPanel {163 CardContent(ONBOARDING_CARDS[active])164 165 166 if (active == cardCount - 1) {167 ChoiceRow(onRestore = onRestore, onAdopt = onAdopt)168 }169 }170 }171172 Spacer(Modifier.weight(1f))173174 NavRow(175 card = card,176 cardCount = cardCount,177 onBack = { if (card > 0) card-- },178 onNext = advance,179 )180 Spacer(Modifier.height(12.dp))181 }182 }183}184185186187188189190private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)191private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)192193194195196197198199200@Composable201private fun CardSwitcher(202 target: Int,203 onClick: () -> Unit,204 modifier: Modifier = Modifier,205 content: @Composable (Int) -> Unit,206) {207 val density = LocalDensity.current208 var shown by remember { mutableIntStateOf(target) }209 val translate = remember { Animatable(0f) }210 val fade = remember { Animatable(1f) }211212 LaunchedEffect(target) {213 if (target == shown) return@LaunchedEffect214 val exitPx = with(density) { (-12).dp.toPx() }215 coroutineScope {216 launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }217 launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }218 }219 delay(60)220 shown = target221 translate.snapTo(with(density) { 12.dp.toPx() })222 coroutineScope {223 launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }224 launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }225 }226 }227228 Box(229 modifier = modifier230 .fillMaxWidth()231 .offset { IntOffset(translate.value.toInt(), 0) }232 .alpha(fade.value)233 234 235 .clickable(236 interactionSource = remember { MutableInteractionSource() },237 indication = null,238 onClick = onClick,239 ),240 ) {241 content(shown)242 }243}244245246247@Composable248private fun GlassCardPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {249 Column(250 modifier = Modifier251 .fillMaxWidth()252 .clip(RoundedCornerShape(18.dp))253 .background(peachColors.SurfaceElevated)254 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))255 .padding(horizontal = 26.dp, vertical = 44.dp),256 content = content,257 )258}259260261262private fun headline() = TextStyle(263 fontFamily = PeachType.Display,264 fontStyle = FontStyle.Italic,265 fontSize = 44.sp,266 lineHeight = (44 * 1.0).sp,267)268269@Composable270private fun CardContent(copy: OnboardingCardCopy) {271 Text(copy.headline, color = peachColors.T0, style = headline())272 Spacer(Modifier.height(24.dp))273 copy.body.forEachIndexed { i, para ->274 if (i > 0) Spacer(Modifier.height(18.dp))275 Text(276 para,277 color = peachColors.T1,278 style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),279 )280 }281 copy.warning?.let { WarningRow(it) }282}283284@Composable285private fun WarningRow(text: String) {286 val accent = LocalPeachAccent.current287 Spacer(Modifier.height(20.dp))288 Column(289 modifier = Modifier290 .fillMaxWidth()291 .drawBehind {292 val dash = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f)293 drawLine(294 color = accent,295 start = Offset(0f, 0f),296 end = Offset(size.width, 0f),297 strokeWidth = 1.dp.toPx(),298 pathEffect = dash,299 )300 }301 .padding(top = 12.dp),302 ) {303 Row(verticalAlignment = Alignment.Top) {304 Text("⚠", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp))305 Spacer(Modifier.width(8.dp))306 Text(307 text,308 color = peachColors.T1,309 style = PeachType.labelMono.copy(fontSize = 10.sp, lineHeight = (10 * 1.5).sp),310 )311 }312 }313}314315316317318internal const val ONBOARD_PAIR_LABEL = "Pair with another device"319internal const val ONBOARD_RESTORE_LABEL = "Restore a backup"320321@Composable322private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit) {323 Spacer(Modifier.height(18.dp))324 Text(325 "Already using Peach?",326 color = peachColors.T1,327 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),328 )329 Spacer(Modifier.height(8.dp))330 ChoiceButton(331 ONBOARD_PAIR_LABEL,332 "Scan a QR and confirm one 8-digit code.",333 onAdopt,334 pro = true,335 )336 Spacer(Modifier.height(8.dp))337 ChoiceButton(338 ONBOARD_RESTORE_LABEL,339 "Use a Peach backup or Codex.",340 onRestore,341 )342}343344@Composable345private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit, pro: Boolean = false) {346 Column(347 modifier = Modifier348 .fillMaxWidth()349 .clip(RoundedCornerShape(8.dp))350 .background(peachColors.T0.copy(alpha = 0.07f))351 .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))352 .clickable(353 interactionSource = remember { MutableInteractionSource() },354 indication = null,355 onClick = onClick,356 )357 .padding(vertical = 9.dp, horizontal = 10.dp),358 ) {359 Row(verticalAlignment = Alignment.CenterVertically) {360 Text(361 label,362 color = peachColors.T0,363 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),364 )365 if (pro) {366 Spacer(Modifier.width(6.dp))367 val accent = LocalPeachAccent.current368 Box(369 Modifier370 .clip(RoundedCornerShape(4.dp))371 .background(accent.copy(alpha = 0.16f))372 .padding(horizontal = 5.dp, vertical = 1.dp),373 ) {374 Text(375 "Pro",376 color = accent.copy(alpha = 0.85f),377 fontFamily = PeachType.Mono,378 fontSize = 8.sp,379 )380 }381 }382 }383 Spacer(Modifier.height(2.dp))384 Text(385 detail,386 color = peachColors.T1,387 style = PeachType.labelMono.copy(fontSize = 9.sp),388 )389 }390}391392393394395internal fun onboardingBackVisible(card: Int): Boolean = card > 0396397398internal fun onboardingNextLabel(card: Int, count: Int): String =399 if (card == count - 1) "create vault" else "next →"400401@Composable402private fun NavRow(403 card: Int,404 cardCount: Int,405 onBack: () -> Unit,406 onNext: () -> Unit,407) {408 val accent = LocalPeachAccent.current409 Row(410 modifier = Modifier.fillMaxWidth(),411 verticalAlignment = Alignment.CenterVertically,412 ) {413 414 415 416 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {417 if (onboardingBackVisible(card)) {418 Text(419 "← back",420 color = peachColors.T2,421 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),422 modifier = Modifier423 .clip(RoundedCornerShape(6.dp))424 .clickable(onClick = onBack)425 .padding(vertical = 6.dp, horizontal = 4.dp),426 )427 }428 }429430 431 Row(432 modifier = Modifier.weight(1f),433 horizontalArrangement = Arrangement.Center,434 verticalAlignment = Alignment.CenterVertically,435 ) {436 repeat(cardCount) { i ->437 val activeDot = i == card438 Box(439 modifier = Modifier440 .padding(horizontal = 4.dp)441 .size(if (activeDot) 8.dp else 6.dp)442 .clip(RoundedCornerShape(999.dp))443 .background(if (activeDot) accent else peachColors.T2),444 )445 }446 }447448 449 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {450 Text(451 onboardingNextLabel(card, cardCount),452 color = peachColors.T1,453 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),454 maxLines = 1,455 modifier = Modifier456 .clip(RoundedCornerShape(6.dp))457 .clickable(onClick = onNext)458 .padding(vertical = 6.dp, horizontal = 4.dp),459 )460 }461 }462}463464465466const val ONBOARD_PREFS = "peach_prefs"467const val ONBOARD_KEY = "peach_onboarded"468469470471472473474475fun onboardingStartDestination(476 hasVault: Boolean,477 onboarded: Boolean,478): com.peachpasswords.ui.PeachNav =479 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding480 else com.peachpasswords.ui.PeachNav.Lock