1package com.peachpasswords.ui.settings23import androidx.activity.compose.BackHandler4import androidx.compose.animation.core.Animatable5import androidx.compose.animation.core.LinearOutSlowInEasing6import androidx.compose.animation.core.Spring7import androidx.compose.animation.core.animateDpAsState8import androidx.compose.animation.core.animateFloat9import androidx.compose.animation.core.animateFloatAsState10import androidx.compose.animation.core.infiniteRepeatable11import androidx.compose.animation.core.keyframes12import androidx.compose.animation.core.rememberInfiniteTransition13import androidx.compose.animation.core.spring14import androidx.compose.animation.core.tween15import androidx.compose.foundation.background16import androidx.compose.foundation.clickable17import androidx.compose.foundation.gestures.detectDragGestures18import androidx.compose.foundation.interaction.MutableInteractionSource19import androidx.compose.foundation.layout.Arrangement20import androidx.compose.foundation.layout.Box21import androidx.compose.foundation.layout.BoxWithConstraints22import androidx.compose.foundation.layout.Column23import androidx.compose.foundation.layout.Row24import androidx.compose.foundation.layout.Spacer25import androidx.compose.foundation.layout.WindowInsets26import androidx.compose.foundation.layout.asPaddingValues27import androidx.compose.foundation.layout.navigationBars28import androidx.compose.foundation.layout.fillMaxSize29import androidx.compose.foundation.layout.fillMaxWidth30import androidx.compose.foundation.layout.height31import androidx.compose.foundation.layout.offset32import androidx.compose.foundation.layout.padding33import androidx.compose.foundation.layout.size34import androidx.compose.foundation.layout.navigationBarsPadding35import androidx.compose.foundation.layout.statusBarsPadding36import androidx.compose.foundation.shape.RoundedCornerShape37import androidx.compose.foundation.rememberScrollState38import androidx.compose.foundation.verticalScroll39import androidx.compose.ui.input.nestedscroll.NestedScrollConnection40import androidx.compose.ui.input.nestedscroll.NestedScrollSource41import androidx.compose.ui.input.nestedscroll.nestedScroll42import androidx.compose.ui.unit.Velocity43import androidx.compose.runtime.Composable44import androidx.compose.runtime.LaunchedEffect45import androidx.compose.runtime.getValue46import androidx.compose.runtime.mutableStateOf47import androidx.compose.runtime.remember48import androidx.compose.runtime.rememberCoroutineScope49import androidx.compose.runtime.rememberUpdatedState50import androidx.compose.runtime.setValue51import androidx.compose.runtime.collectAsState52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.drawWithCache56import androidx.compose.ui.draw.shadow57import androidx.compose.ui.draw.drawBehind58import androidx.compose.ui.geometry.Offset59import androidx.compose.ui.graphics.BlendMode60import androidx.compose.ui.graphics.Brush61import androidx.compose.ui.graphics.Color62import androidx.compose.ui.graphics.drawscope.DrawScope63import androidx.compose.ui.graphics.graphicsLayer64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.platform.LocalDensity66import androidx.compose.ui.unit.Dp67import androidx.compose.ui.text.font.FontStyle68import androidx.compose.ui.text.font.FontWeight69import androidx.compose.ui.text.style.TextOverflow70import androidx.compose.ui.unit.dp71import androidx.compose.ui.unit.em72import androidx.compose.ui.unit.sp73import androidx.compose.ui.zIndex74import androidx.hilt.navigation.compose.hiltViewModel75import androidx.compose.material3.Text76import androidx.compose.ui.text.style.TextAlign77import com.peachpasswords.BuildConfig78import com.peachpasswords.ui.components.PeachTopBar79import com.peachpasswords.ui.theme.LocalPeachAccent80import com.peachpasswords.ui.theme.LocalReduceMotion81import com.peachpasswords.ui.theme.PeachSpacing82import com.peachpasswords.ui.theme.peachColors83import com.peachpasswords.ui.theme.PeachMotion84import com.peachpasswords.ui.theme.PeachTheme85import com.peachpasswords.ui.theme.PeachStackColors86import com.peachpasswords.ui.theme.PeachType87import com.peachpasswords.ui.theme.ShellBackground88import kotlinx.coroutines.delay89import kotlinx.coroutines.launch9091929394private val EXPANDED_H = 258.dp95private val TAB_H = 46.dp96private val TAB_OVERLAP = 14.dp97private val TAB_CARD_H = TAB_H + 4.dp9899100101102103104private val VERSION_LABEL_FOOTPRINT = PeachSpacing.xs + 12.dp105106107private val DECK_TO_VERSION_GAP = 1.dp108109110private val DRAG_THRESHOLD = 60.dp111112113internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }114115116117118119120internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {121 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND122 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE123 else -> DragOutcome.NONE124}125126127128129130131132private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP133134135136137138139140internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =141 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance142143144145146147148149150151152153154internal fun settingsDeckHeight(availableHeight: Dp, navBarBottom: Dp): Dp {155 val reserved = navBarBottom + VERSION_LABEL_FOOTPRINT + DECK_TO_VERSION_GAP156 return (availableHeight - reserved).coerceAtLeast(0.dp)157}158159@Composable160fun SettingsScreen(161 onBack: () -> Unit,162 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},163 vm: SettingsViewModel = hiltViewModel(),164) {165 val entries by vm.entries.collectAsState()166 val settings by vm.settings.collectAsState()167 val isPro by vm.isPro.collectAsState()168 val showCelebration by vm.showCelebration.collectAsState()169 val reduceMotion = LocalReduceMotion.current170 val accent = LocalPeachAccent.current171 val scope = rememberCoroutineScope()172173 var active by remember { mutableStateOf(CardId.Pro) }174 var deckOpen by remember { mutableStateOf(true) }175 var fanning by remember { mutableStateOf(false) }176 var celebrating by remember { mutableStateOf<CardId?>(null) }177178 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)179180 fun handleBack() {181 if (!deckOpen) deckOpen = true else onBack()182 }183 BackHandler { handleBack() }184185 186 fun celebrate() {187 if (fanning) return188 scope.launch {189 fanning = true190 active = CardId.Pro191 CELEBRATION_FAN.forEachIndexed { i, id ->192 delay(150L)193 active = id194 }195 delay(150L)196 active = CardId.Pro197 fanning = false198 celebrating = CardId.Pro199 200 201 delay(3200L)202 celebrating = null203 }204 }205206 PeachTheme(accent = accent, reduceMotion = reduceMotion) {207 ShellBackground {208 Column(209 Modifier210 .fillMaxSize()211 .statusBarsPadding()212 .padding(horizontal = PeachSpacing.md),213 ) {214 SettingsTopBar(215 activeIndex = activeIndex,216 total = STACK_CARDS.size,217 onBack = { handleBack() },218 )219 Spacer(Modifier.height(PeachSpacing.sm))220221 BoxWithConstraints(Modifier.fillMaxSize()) {222 val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()223 val deckHeight = settingsDeckHeight(maxHeight, navBarBottom)224 val focusHeight = deckHeight - 8.dp225 226 227 228 229 230 231 val expandedH = (232 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H233 ).coerceAtLeast(EXPANDED_H)234 Box(Modifier.fillMaxWidth().height(deckHeight)) {235 STACK_CARDS.forEachIndexed { index, card ->236 val isActive = card.id == active237 val distance = when {238 index == activeIndex -> -1239 index > activeIndex -> index - activeIndex - 1240 else -> STACK_CARDS.size - activeIndex + index - 1241 }242 val targetTop = when {243 isActive -> 0.dp244 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp245 else -> collapsedTabTop(distance, expandedH)246 }247 val targetHeight = when {248 isActive && deckOpen -> expandedH249 isActive -> focusHeight250 else -> TAB_CARD_H251 }252 val concealed = !deckOpen && !isActive253254 255 256 257 258 259 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)260 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)261 val top by animateDpAsState(262 targetTop,263 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,264 label = "top",265 )266 val cardH by animateDpAsState(267 targetHeight,268 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,269 label = "h",270 )271 val alpha by animateFloatAsState(272 if (concealed) 0f else 1f,273 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,274 label = "a",275 )276 val zIndex = if (isActive) 99f else (50 - distance).toFloat()277278 val density = LocalDensity.current279 StackCard(280 spec = card,281 active = isActive,282 focused = isActive && !deckOpen,283 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),284 showSparkle = card.id == CardId.Pro && isPro,285 celebrating = celebrating == card.id,286 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,287 body = {288 CardBody(289 id = card.id,290 fg = card.fg,291 vm = vm,292 settings = settings,293 entriesCount = entries.count { it.deletedAt == null },294 onNavigate = onNavigate,295 )296 },297 modifier = Modifier298 .fillMaxWidth()299 .align(Alignment.TopStart)300 .zIndex(zIndex)301 302 303 304 305 306 307 308 309 310 311 312 .then(313 if (!isActive) {314 Modifier.pointerInput(card.id, deckOpen) {315 var dragY = 0f316 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }317 detectDragGestures(318 onDragStart = { dragY = 0f },319 onDrag = { change, amount -> change.consume(); dragY += amount.y },320 onDragEnd = {321 if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {322 active = card.id323 deckOpen = false324 }325 },326 )327 }328 } else {329 Modifier330 },331 )332 .graphicsLayer {333 translationY = top.toPx()334 this.alpha = alpha335 }336 .height(cardH),337 onClick = {338 339 340 if (!isActive) {341 active = card.id342 deckOpen = false343 }344 },345 onCollapseRequest = { deckOpen = true },346 onExpandRequest = { active = card.id; deckOpen = false },347 enabled = !concealed,348 )349 }350 }351352 353 354 355 356 357 Text(358 "v${BuildConfig.VERSION_NAME}",359 color = peachColors.T2.copy(alpha = 0.5f),360 fontFamily = PeachType.Mono,361 fontSize = 9.sp,362 textAlign = TextAlign.Center,363 modifier = Modifier364 .align(Alignment.BottomCenter)365 .fillMaxWidth()366 367 368 .navigationBarsPadding()369 .padding(bottom = PeachSpacing.xs),370 )371 }372 }373 if (showCelebration) {374 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })375 }376 }377 }378}379380@Composable381private fun StackCard(382 spec: StackCardSpec,383 active: Boolean,384 focused: Boolean,385 lede: String,386 showSparkle: Boolean,387 celebrating: Boolean,388 onIconClick: (() -> Unit)?,389 body: @Composable () -> Unit,390 modifier: Modifier,391 onClick: () -> Unit,392 onCollapseRequest: () -> Unit,393 onExpandRequest: () -> Unit,394 enabled: Boolean,395) {396 val interaction = remember { MutableInteractionSource() }397 Box(398 modifier399 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)400 .clip(RoundedCornerShape(PeachStackColors.CardRadius))401 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }402 .clickable(403 interactionSource = interaction,404 indication = null,405 enabled = enabled && !active || (active && !focused),406 ) { onClick() }407 .padding(408 horizontal = if (active) 18.dp else 16.dp,409 vertical = if (active) 16.dp else 8.dp,410 ),411 ) {412 if (showSparkle) SparkleDust(Modifier.fillMaxSize())413 if (active) {414 Column(Modifier.fillMaxSize()) {415 Row(416 Modifier.fillMaxWidth(),417 horizontalArrangement = Arrangement.SpaceBetween,418 verticalAlignment = Alignment.CenterVertically,419 ) {420 Text(421 spec.num,422 color = spec.fg.copy(alpha = 0.7f),423 fontFamily = PeachType.Mono,424 fontSize = 8.5.sp,425 letterSpacing = 0.26.em,426 )427 Box(contentAlignment = Alignment.Center) {428 Box(429 Modifier.clip(RoundedCornerShape(999.dp))430 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)431 .padding(2.dp),432 ) {433 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))434 }435 436 437 438 if (celebrating) ProSparkBurst(color = spec.fg)439 }440 }441 Text(442 spec.title,443 modifier = Modifier.padding(top = 6.dp),444 color = spec.fg,445 fontFamily = PeachType.Display,446 fontStyle = FontStyle.Italic,447 fontSize = 26.sp,448 )449 Text(450 lede,451 modifier = Modifier.padding(top = 4.dp),452 color = spec.fg.copy(alpha = 0.72f),453 fontFamily = PeachType.Display,454 fontStyle = FontStyle.Italic,455 fontSize = 12.5.sp,456 )457 if (celebrating) ProThankYouFlash(color = spec.fg)458 459 460 461 462 463 464 465 466 467 468 469 val scrollState = rememberScrollState()470 val density = LocalDensity.current471 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }472 473 474 475 val latestFocused = rememberUpdatedState(focused)476 val latestActive = rememberUpdatedState(active)477 val nestedScrollConnection = remember(scrollState) {478 object : NestedScrollConnection {479 var expandAccum = 0f480 var collapseAccum = 0f481 482 483 484 485 486 487 488 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {489 if (!latestActive.value || latestFocused.value || source != NestedScrollSource.UserInput || available.y == 0f) {490 return Offset.Zero491 }492 expandAccum += available.y493 return available494 }495 override fun onPostScroll(496 consumed: Offset,497 available: Offset,498 source: NestedScrollSource,499 ): Offset {500 if (!latestFocused.value || source != NestedScrollSource.UserInput || scrollState.value != 0 || available.y <= 0f) {501 return Offset.Zero502 }503 collapseAccum += available.y504 return Offset(0f, available.y)505 }506 override suspend fun onPreFling(available: Velocity): Velocity {507 508 509 510 511 512 if (expandAccum <= -gestureThresholdPx) {513 onExpandRequest()514 } else if (collapseAccum >= gestureThresholdPx) {515 onCollapseRequest()516 }517 expandAccum = 0f518 collapseAccum = 0f519 return Velocity.Zero520 }521 }522 }523 Column(524 Modifier.fillMaxWidth().weight(1f)525 .padding(top = 14.dp)526 .nestedScroll(nestedScrollConnection)527 .verticalScroll(scrollState),528 ) {529 body()530 531 532 533 Spacer(Modifier.height(PeachSpacing.xl))534 }535 }536 } else {537 538 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {539 Text(540 spec.title,541 color = spec.fg,542 fontFamily = PeachType.Display,543 fontStyle = FontStyle.Italic,544 fontWeight = FontWeight.Normal,545 fontSize = 19.sp,546 maxLines = 1,547 overflow = TextOverflow.Ellipsis,548 )549 }550 }551 }552}553554@Composable555private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {556 PeachTopBar(557 title = "Settings",558 leading = {559 Box(560 Modifier561 .size(40.dp)562 .clip(RoundedCornerShape(999.dp))563 .clickable(onClick = onBack),564 contentAlignment = Alignment.Center,565 ) {566 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)567 }568 },569 actions = {570 Text(571 "${activeIndex + 1} / $total",572 color = peachColors.T2,573 fontFamily = PeachType.Mono,574 fontSize = 9.sp,575 letterSpacing = 0.22.em,576 modifier = Modifier.padding(horizontal = PeachSpacing.sm),577 )578 },579 )580}581582583584585586587588private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {589 val a = Math.toRadians(155.0)590 val dx = kotlin.math.sin(a).toFloat()591 val dy = -kotlin.math.cos(a).toFloat()592 val cx = size.width / 2f593 val cy = size.height / 2f594 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f595 return Brush.linearGradient(596 colors = listOf(start, end),597 start = Offset(cx - dx * half, cy - dy * half),598 end = Offset(cx + dx * half, cy + dy * half),599 )600}601602private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)603604605606private val PRO_SPARKS = listOf(607 ProSpark(0f, -54f, 0, "✦"),608 ProSpark(38f, -38f, 50, "✦"),609 ProSpark(54f, 0f, 110, "·"),610 ProSpark(38f, 38f, 30, "✦"),611 ProSpark(0f, 54f, 80, "·"),612 ProSpark(-38f, 38f, 130, "✦"),613 ProSpark(-54f, 0f, 55, "·"),614 ProSpark(-38f, -38f, 95, "✦"),615)616617618619620621622623@Composable624private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {625 if (LocalReduceMotion.current) return626 Box(modifier) {627 PRO_SPARKS.forEach { spark ->628 val progress = remember(spark) { Animatable(0f) }629 LaunchedEffect(spark) {630 delay(spark.delayMs.toLong())631 progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))632 }633 val t = progress.value634 val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f635 val scale = 0.5f + 0.3f * t636 Text(637 spark.glyph,638 color = color.copy(alpha = alpha.coerceIn(0f, 1f)),639 fontSize = 9.sp,640 modifier = Modifier641 .align(Alignment.Center)642 .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)643 .graphicsLayer { scaleX = scale; scaleY = scale },644 )645 }646 }647}648649650651652653654@Composable655private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {656 val reduceMotion = LocalReduceMotion.current657 val alpha = remember { Animatable(0f) }658 LaunchedEffect(Unit) {659 if (reduceMotion) {660 alpha.snapTo(0.75f)661 } else {662 alpha.animateTo(663 0f,664 keyframes {665 durationMillis = 2500666 0f at 0667 0.75f at 375668 0.75f at 1875669 0f at 2500670 },671 )672 }673 }674 Text(675 "thank you ✦",676 modifier = modifier.padding(top = 4.dp),677 color = color.copy(alpha = alpha.value),678 fontFamily = PeachType.Display,679 fontStyle = FontStyle.Italic,680 fontSize = 11.sp,681 )682}683684685686687688689@Composable690private fun SparkleDust(modifier: Modifier = Modifier) {691 val reduceMotion = LocalReduceMotion.current692 val t = rememberInfiniteTransition(label = "dust")693 val drift by if (reduceMotion) {694 remember { mutableStateOf(0f) }695 } else {696 t.animateFloat(697 initialValue = 0f,698 targetValue = 1f,699 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),700 label = "drift",701 )702 }703 Box(704 modifier.drawWithCache {705 val tile = 48.dp.toPx()706 val dx = drift * tile707 onDrawBehind {708 val cols = (size.width / tile).toInt() + 2709 val rows = (size.height / tile).toInt() + 2710 for (r in 0..rows) {711 for (c in 0..cols) {712 val baseX = c * tile + dx713 val baseY = r * tile + dx714 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)715 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)716 }717 }718 }719 },720 )721}