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.fillMaxSize26import androidx.compose.foundation.layout.fillMaxWidth27import androidx.compose.foundation.layout.height28import androidx.compose.foundation.layout.offset29import androidx.compose.foundation.layout.padding30import androidx.compose.foundation.layout.size31import androidx.compose.foundation.layout.statusBarsPadding32import androidx.compose.foundation.shape.RoundedCornerShape33import androidx.compose.foundation.rememberScrollState34import androidx.compose.foundation.verticalScroll35import androidx.compose.ui.input.nestedscroll.NestedScrollConnection36import androidx.compose.ui.input.nestedscroll.NestedScrollSource37import androidx.compose.ui.input.nestedscroll.nestedScroll38import androidx.compose.ui.unit.Velocity39import androidx.compose.runtime.Composable40import androidx.compose.runtime.LaunchedEffect41import androidx.compose.runtime.getValue42import androidx.compose.runtime.mutableStateOf43import androidx.compose.runtime.remember44import androidx.compose.runtime.rememberCoroutineScope45import androidx.compose.runtime.rememberUpdatedState46import androidx.compose.runtime.setValue47import androidx.compose.runtime.collectAsState48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.draw.drawWithCache52import androidx.compose.ui.draw.shadow53import androidx.compose.ui.draw.drawBehind54import androidx.compose.ui.geometry.Offset55import androidx.compose.ui.graphics.BlendMode56import androidx.compose.ui.graphics.Brush57import androidx.compose.ui.graphics.Color58import androidx.compose.ui.graphics.drawscope.DrawScope59import androidx.compose.ui.graphics.graphicsLayer60import androidx.compose.ui.input.pointer.pointerInput61import androidx.compose.ui.platform.LocalDensity62import androidx.compose.ui.unit.Dp63import androidx.compose.ui.text.font.FontStyle64import androidx.compose.ui.text.font.FontWeight65import androidx.compose.ui.text.style.TextOverflow66import androidx.compose.ui.unit.dp67import androidx.compose.ui.unit.em68import androidx.compose.ui.unit.sp69import androidx.compose.ui.zIndex70import androidx.hilt.navigation.compose.hiltViewModel71import androidx.compose.material3.Text72import com.peachpasswords.ui.components.PeachTopBar73import com.peachpasswords.ui.theme.LocalPeachAccent74import com.peachpasswords.ui.theme.LocalReduceMotion75import com.peachpasswords.ui.theme.PeachSpacing76import com.peachpasswords.ui.theme.peachColors77import com.peachpasswords.ui.theme.PeachMotion78import com.peachpasswords.ui.theme.PeachTheme79import com.peachpasswords.ui.theme.PeachStackColors80import com.peachpasswords.ui.theme.PeachType81import com.peachpasswords.ui.theme.ShellBackground82import kotlinx.coroutines.delay83import kotlinx.coroutines.launch8485868788private val EXPANDED_H = 258.dp89private val TAB_H = 46.dp90private val TAB_OVERLAP = 14.dp91private val TAB_CARD_H = TAB_H + 4.dp92private val MAX_DECK_H = 462.dp93private val DECK_BOTTOM_GUTTER = 36.dp949596private val DRAG_THRESHOLD = 60.dp979899internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }100101102103104105106internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {107 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND108 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE109 else -> DragOutcome.NONE110}111112113114115116117118private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP119120121122123124125126internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =127 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance128129130131132133134135136137internal fun settingsDeckHeight(availableHeight: Dp): Dp {138 val constrained = availableHeight - DECK_BOTTOM_GUTTER139 return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)140}141142@Composable143fun SettingsScreen(144 onBack: () -> Unit,145 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},146 vm: SettingsViewModel = hiltViewModel(),147) {148 val entries by vm.entries.collectAsState()149 val settings by vm.settings.collectAsState()150 val isPro by vm.isPro.collectAsState()151 val showCelebration by vm.showCelebration.collectAsState()152 val reduceMotion = LocalReduceMotion.current153 val accent = LocalPeachAccent.current154 val scope = rememberCoroutineScope()155156 var active by remember { mutableStateOf(CardId.Pro) }157 var deckOpen by remember { mutableStateOf(true) }158 var fanning by remember { mutableStateOf(false) }159 var celebrating by remember { mutableStateOf<CardId?>(null) }160161 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)162163 fun handleBack() {164 if (!deckOpen) deckOpen = true else onBack()165 }166 BackHandler { handleBack() }167168 169 fun celebrate() {170 if (fanning) return171 scope.launch {172 fanning = true173 active = CardId.Pro174 CELEBRATION_FAN.forEachIndexed { i, id ->175 delay(150L)176 active = id177 }178 delay(150L)179 active = CardId.Pro180 fanning = false181 celebrating = CardId.Pro182 183 184 delay(3200L)185 celebrating = null186 }187 }188189 190 191 192 193 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {194 ShellBackground {195 Column(196 Modifier197 .fillMaxSize()198 .statusBarsPadding()199 .padding(horizontal = PeachSpacing.md),200 ) {201 SettingsTopBar(202 activeIndex = activeIndex,203 total = STACK_CARDS.size,204 onBack = { handleBack() },205 )206 Spacer(Modifier.height(PeachSpacing.sm))207208 BoxWithConstraints(Modifier.fillMaxSize()) {209 val deckHeight = settingsDeckHeight(maxHeight)210 val focusHeight = deckHeight - 8.dp211 212 213 214 215 216 217 val expandedH = (218 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H219 ).coerceAtLeast(EXPANDED_H)220 Box(Modifier.fillMaxWidth().height(deckHeight)) {221 STACK_CARDS.forEachIndexed { index, card ->222 val isActive = card.id == active223 val distance = when {224 index == activeIndex -> -1225 index > activeIndex -> index - activeIndex - 1226 else -> STACK_CARDS.size - activeIndex + index - 1227 }228 val targetTop = when {229 isActive -> 0.dp230 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp231 else -> collapsedTabTop(distance, expandedH)232 }233 val targetHeight = when {234 isActive && deckOpen -> expandedH235 isActive -> focusHeight236 else -> TAB_CARD_H237 }238 val concealed = !deckOpen && !isActive239240 241 242 243 244 245 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)246 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)247 val top by animateDpAsState(248 targetTop,249 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,250 label = "top",251 )252 val cardH by animateDpAsState(253 targetHeight,254 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,255 label = "h",256 )257 val alpha by animateFloatAsState(258 if (concealed) 0f else 1f,259 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,260 label = "a",261 )262 val zIndex = if (isActive) 99f else (50 - distance).toFloat()263264 val density = LocalDensity.current265 StackCard(266 spec = card,267 active = isActive,268 focused = isActive && !deckOpen,269 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),270 showSparkle = card.id == CardId.Pro && isPro,271 celebrating = celebrating == card.id,272 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,273 body = {274 CardBody(275 id = card.id,276 fg = card.fg,277 vm = vm,278 settings = settings,279 entriesCount = entries.count { it.deletedAt == null },280 onNavigate = onNavigate,281 )282 },283 modifier = Modifier284 .fillMaxWidth()285 .align(Alignment.TopStart)286 .zIndex(zIndex)287 288 289 290 291 292 293 294 295 296 297 298 .then(299 if (!isActive) {300 Modifier.pointerInput(card.id, deckOpen) {301 var dragY = 0f302 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }303 detectDragGestures(304 onDragStart = { dragY = 0f },305 onDrag = { change, amount -> change.consume(); dragY += amount.y },306 onDragEnd = {307 if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {308 active = card.id309 deckOpen = false310 }311 },312 )313 }314 } else {315 Modifier316 },317 )318 .graphicsLayer {319 translationY = top.toPx()320 this.alpha = alpha321 }322 .height(cardH),323 onClick = {324 325 326 if (!isActive) {327 active = card.id328 deckOpen = false329 }330 },331 onCollapseRequest = { deckOpen = true },332 onExpandRequest = { active = card.id; deckOpen = false },333 enabled = !concealed,334 )335 }336 }337 }338 }339 if (showCelebration) {340 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })341 }342 }343 }344}345346@Composable347private fun StackCard(348 spec: StackCardSpec,349 active: Boolean,350 focused: Boolean,351 lede: String,352 showSparkle: Boolean,353 celebrating: Boolean,354 onIconClick: (() -> Unit)?,355 body: @Composable () -> Unit,356 modifier: Modifier,357 onClick: () -> Unit,358 onCollapseRequest: () -> Unit,359 onExpandRequest: () -> Unit,360 enabled: Boolean,361) {362 val interaction = remember { MutableInteractionSource() }363 Box(364 modifier365 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)366 .clip(RoundedCornerShape(PeachStackColors.CardRadius))367 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }368 .clickable(369 interactionSource = interaction,370 indication = null,371 enabled = enabled && !active || (active && !focused),372 ) { onClick() }373 .padding(374 horizontal = if (active) 18.dp else 16.dp,375 vertical = if (active) 16.dp else 12.dp,376 ),377 ) {378 if (showSparkle) SparkleDust(Modifier.fillMaxSize())379 if (active) {380 Column(Modifier.fillMaxSize()) {381 Row(382 Modifier.fillMaxWidth(),383 horizontalArrangement = Arrangement.SpaceBetween,384 verticalAlignment = Alignment.CenterVertically,385 ) {386 Text(387 spec.num,388 color = spec.fg.copy(alpha = 0.7f),389 fontFamily = PeachType.Mono,390 fontSize = 8.5.sp,391 letterSpacing = 0.26.em,392 )393 Box(contentAlignment = Alignment.Center) {394 Box(395 Modifier.clip(RoundedCornerShape(999.dp))396 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)397 .padding(2.dp),398 ) {399 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))400 }401 402 403 404 if (celebrating) ProSparkBurst(color = spec.fg)405 }406 }407 Text(408 spec.title,409 modifier = Modifier.padding(top = 6.dp),410 color = spec.fg,411 fontFamily = PeachType.Display,412 fontStyle = FontStyle.Italic,413 fontSize = 26.sp,414 )415 Text(416 lede,417 modifier = Modifier.padding(top = 4.dp),418 color = spec.fg.copy(alpha = 0.72f),419 fontFamily = PeachType.Display,420 fontStyle = FontStyle.Italic,421 fontSize = 12.5.sp,422 )423 if (celebrating) ProThankYouFlash(color = spec.fg)424 425 426 427 428 429 430 431 432 433 434 435 val scrollState = rememberScrollState()436 val density = LocalDensity.current437 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }438 439 440 441 val latestFocused = rememberUpdatedState(focused)442 val latestActive = rememberUpdatedState(active)443 val nestedScrollConnection = remember(scrollState) {444 object : NestedScrollConnection {445 var expandAccum = 0f446 var collapseAccum = 0f447 448 449 450 451 452 453 454 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {455 if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {456 return Offset.Zero457 }458 expandAccum += available.y459 return available460 }461 override fun onPostScroll(462 consumed: Offset,463 available: Offset,464 source: NestedScrollSource,465 ): Offset {466 if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {467 return Offset.Zero468 }469 collapseAccum += available.y470 return Offset(0f, available.y)471 }472 override suspend fun onPreFling(available: Velocity): Velocity {473 474 475 476 477 478 if (expandAccum <= -gestureThresholdPx) {479 onExpandRequest()480 } else if (collapseAccum >= gestureThresholdPx) {481 onCollapseRequest()482 }483 expandAccum = 0f484 collapseAccum = 0f485 return Velocity.Zero486 }487 }488 }489 Column(490 Modifier.fillMaxWidth().weight(1f)491 .padding(top = 14.dp)492 .nestedScroll(nestedScrollConnection)493 .verticalScroll(scrollState),494 ) {495 body()496 497 498 499 Spacer(Modifier.height(PeachSpacing.xl))500 }501 }502 } else {503 504 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {505 Text(506 spec.title,507 color = spec.fg,508 fontFamily = PeachType.Display,509 fontStyle = FontStyle.Italic,510 fontWeight = FontWeight.Normal,511 fontSize = 19.sp,512 maxLines = 1,513 overflow = TextOverflow.Ellipsis,514 )515 }516 }517 }518}519520@Composable521private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {522 PeachTopBar(523 title = "Settings",524 leading = {525 Box(526 Modifier527 .size(40.dp)528 .clip(RoundedCornerShape(999.dp))529 .clickable(onClick = onBack),530 contentAlignment = Alignment.Center,531 ) {532 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)533 }534 },535 actions = {536 Text(537 "${activeIndex + 1} / $total",538 color = peachColors.T2,539 fontFamily = PeachType.Mono,540 fontSize = 9.sp,541 letterSpacing = 0.22.em,542 modifier = Modifier.padding(horizontal = PeachSpacing.sm),543 )544 },545 )546}547548549550551552553554private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {555 val a = Math.toRadians(155.0)556 val dx = kotlin.math.sin(a).toFloat()557 val dy = -kotlin.math.cos(a).toFloat()558 val cx = size.width / 2f559 val cy = size.height / 2f560 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f561 return Brush.linearGradient(562 colors = listOf(start, end),563 start = Offset(cx - dx * half, cy - dy * half),564 end = Offset(cx + dx * half, cy + dy * half),565 )566}567568private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)569570571572private val PRO_SPARKS = listOf(573 ProSpark(0f, -54f, 0, "✦"),574 ProSpark(38f, -38f, 50, "✦"),575 ProSpark(54f, 0f, 110, "·"),576 ProSpark(38f, 38f, 30, "✦"),577 ProSpark(0f, 54f, 80, "·"),578 ProSpark(-38f, 38f, 130, "✦"),579 ProSpark(-54f, 0f, 55, "·"),580 ProSpark(-38f, -38f, 95, "✦"),581)582583584585586587588589@Composable590private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {591 if (LocalReduceMotion.current) return592 Box(modifier) {593 PRO_SPARKS.forEach { spark ->594 val progress = remember(spark) { Animatable(0f) }595 LaunchedEffect(spark) {596 delay(spark.delayMs.toLong())597 progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))598 }599 val t = progress.value600 val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f601 val scale = 0.5f + 0.3f * t602 Text(603 spark.glyph,604 color = color.copy(alpha = alpha.coerceIn(0f, 1f)),605 fontSize = 9.sp,606 modifier = Modifier607 .align(Alignment.Center)608 .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)609 .graphicsLayer { scaleX = scale; scaleY = scale },610 )611 }612 }613}614615616617618619620@Composable621private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {622 val reduceMotion = LocalReduceMotion.current623 val alpha = remember { Animatable(0f) }624 LaunchedEffect(Unit) {625 if (reduceMotion) {626 alpha.snapTo(0.75f)627 } else {628 alpha.animateTo(629 0f,630 keyframes {631 durationMillis = 2500632 0f at 0633 0.75f at 375634 0.75f at 1875635 0f at 2500636 },637 )638 }639 }640 Text(641 "thank you ✦",642 modifier = modifier.padding(top = 4.dp),643 color = color.copy(alpha = alpha.value),644 fontFamily = PeachType.Display,645 fontStyle = FontStyle.Italic,646 fontSize = 11.sp,647 )648}649650651652653654655@Composable656private fun SparkleDust(modifier: Modifier = Modifier) {657 val reduceMotion = LocalReduceMotion.current658 val t = rememberInfiniteTransition(label = "dust")659 val drift by if (reduceMotion) {660 remember { mutableStateOf(0f) }661 } else {662 t.animateFloat(663 initialValue = 0f,664 targetValue = 1f,665 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),666 label = "drift",667 )668 }669 Box(670 modifier.drawWithCache {671 val tile = 48.dp.toPx()672 val dx = drift * tile673 onDrawBehind {674 val cols = (size.width / tile).toInt() + 2675 val rows = (size.height / tile).toInt() + 2676 for (r in 0..rows) {677 for (c in 0..cols) {678 val baseX = c * tile + dx679 val baseY = r * tile + dx680 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)681 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)682 }683 }684 }685 },686 )687}