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 )335 Spacer(Modifier.height(8.dp))336 ChoiceButton(337 ONBOARD_RESTORE_LABEL,338 "Use a Peach backup or Codex.",339 onRestore,340 )341}342343@Composable344private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit) {345 Column(346 modifier = Modifier347 .fillMaxWidth()348 .clip(RoundedCornerShape(8.dp))349 .background(peachColors.T0.copy(alpha = 0.07f))350 .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))351 .clickable(352 interactionSource = remember { MutableInteractionSource() },353 indication = null,354 onClick = onClick,355 )356 .padding(vertical = 9.dp, horizontal = 10.dp),357 ) {358 Text(359 label,360 color = peachColors.T0,361 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),362 )363 Spacer(Modifier.height(2.dp))364 Text(365 detail,366 color = peachColors.T1,367 style = PeachType.labelMono.copy(fontSize = 9.sp),368 )369 }370}371372373374375internal fun onboardingBackVisible(card: Int): Boolean = card > 0376377378internal fun onboardingNextLabel(card: Int, count: Int): String =379 if (card == count - 1) "create vault" else "next →"380381@Composable382private fun NavRow(383 card: Int,384 cardCount: Int,385 onBack: () -> Unit,386 onNext: () -> Unit,387) {388 val accent = LocalPeachAccent.current389 Row(390 modifier = Modifier.fillMaxWidth(),391 verticalAlignment = Alignment.CenterVertically,392 ) {393 394 395 396 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {397 if (onboardingBackVisible(card)) {398 Text(399 "← back",400 color = peachColors.T2,401 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),402 modifier = Modifier403 .clip(RoundedCornerShape(6.dp))404 .clickable(onClick = onBack)405 .padding(vertical = 6.dp, horizontal = 4.dp),406 )407 }408 }409410 411 Row(412 modifier = Modifier.weight(1f),413 horizontalArrangement = Arrangement.Center,414 verticalAlignment = Alignment.CenterVertically,415 ) {416 repeat(cardCount) { i ->417 val activeDot = i == card418 Box(419 modifier = Modifier420 .padding(horizontal = 4.dp)421 .size(if (activeDot) 8.dp else 6.dp)422 .clip(RoundedCornerShape(999.dp))423 .background(if (activeDot) accent else peachColors.T2),424 )425 }426 }427428 429 Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {430 Text(431 onboardingNextLabel(card, cardCount),432 color = peachColors.T1,433 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),434 maxLines = 1,435 modifier = Modifier436 .clip(RoundedCornerShape(6.dp))437 .clickable(onClick = onNext)438 .padding(vertical = 6.dp, horizontal = 4.dp),439 )440 }441 }442}443444445446const val ONBOARD_PREFS = "peach_prefs"447const val ONBOARD_KEY = "peach_onboarded"448449450451452453454455fun onboardingStartDestination(456 hasVault: Boolean,457 onboarded: Boolean,458): com.peachpasswords.ui.PeachNav =459 if (!hasVault && !onboarded) com.peachpasswords.ui.PeachNav.Onboarding460 else com.peachpasswords.ui.PeachNav.Lock