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.navigationBarsPadding32import androidx.compose.foundation.layout.statusBarsPadding33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.rememberScrollState35import androidx.compose.foundation.verticalScroll36import androidx.compose.ui.input.nestedscroll.NestedScrollConnection37import androidx.compose.ui.input.nestedscroll.NestedScrollSource38import androidx.compose.ui.input.nestedscroll.nestedScroll39import androidx.compose.ui.unit.Velocity40import androidx.compose.runtime.Composable41import androidx.compose.runtime.LaunchedEffect42import androidx.compose.runtime.getValue43import androidx.compose.runtime.mutableStateOf44import androidx.compose.runtime.remember45import androidx.compose.runtime.rememberCoroutineScope46import androidx.compose.runtime.rememberUpdatedState47import androidx.compose.runtime.setValue48import androidx.compose.runtime.collectAsState49import androidx.compose.ui.Alignment50import androidx.compose.ui.Modifier51import androidx.compose.ui.draw.clip52import androidx.compose.ui.draw.drawWithCache53import androidx.compose.ui.draw.shadow54import androidx.compose.ui.draw.drawBehind55import androidx.compose.ui.geometry.Offset56import androidx.compose.ui.graphics.BlendMode57import androidx.compose.ui.graphics.Brush58import androidx.compose.ui.graphics.Color59import androidx.compose.ui.graphics.drawscope.DrawScope60import androidx.compose.ui.graphics.graphicsLayer61import androidx.compose.ui.input.pointer.pointerInput62import androidx.compose.ui.platform.LocalDensity63import androidx.compose.ui.unit.Dp64import androidx.compose.ui.text.font.FontStyle65import androidx.compose.ui.text.font.FontWeight66import androidx.compose.ui.text.style.TextOverflow67import androidx.compose.ui.unit.dp68import androidx.compose.ui.unit.em69import androidx.compose.ui.unit.sp70import androidx.compose.ui.zIndex71import androidx.hilt.navigation.compose.hiltViewModel72import androidx.compose.material3.Text73import androidx.compose.ui.text.style.TextAlign74import com.peachpasswords.BuildConfig75import com.peachpasswords.ui.components.PeachTopBar76import com.peachpasswords.ui.theme.LocalPeachAccent77import com.peachpasswords.ui.theme.LocalReduceMotion78import com.peachpasswords.ui.theme.PeachSpacing79import com.peachpasswords.ui.theme.peachColors80import com.peachpasswords.ui.theme.PeachMotion81import com.peachpasswords.ui.theme.PeachTheme82import com.peachpasswords.ui.theme.PeachStackColors83import com.peachpasswords.ui.theme.PeachType84import com.peachpasswords.ui.theme.ShellBackground85import kotlinx.coroutines.delay86import kotlinx.coroutines.launch8788899091private val EXPANDED_H = 258.dp92private val TAB_H = 46.dp93private val TAB_OVERLAP = 14.dp94private val TAB_CARD_H = TAB_H + 4.dp95private val DECK_BOTTOM_GUTTER = 36.dp969798private val DRAG_THRESHOLD = 60.dp99100101internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }102103104105106107108internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {109 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND110 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE111 else -> DragOutcome.NONE112}113114115116117118119120private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP121122123124125126127128internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =129 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance130131132133134135136137138139140141internal fun settingsDeckHeight(availableHeight: Dp): Dp {142 val constrained = availableHeight - DECK_BOTTOM_GUTTER143 return if (constrained < 0.dp) 0.dp else constrained144}145146@Composable147fun SettingsScreen(148 onBack: () -> Unit,149 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},150 vm: SettingsViewModel = hiltViewModel(),151) {152 val entries by vm.entries.collectAsState()153 val settings by vm.settings.collectAsState()154 val isPro by vm.isPro.collectAsState()155 val showCelebration by vm.showCelebration.collectAsState()156 val reduceMotion = LocalReduceMotion.current157 val accent = LocalPeachAccent.current158 val scope = rememberCoroutineScope()159160 var active by remember { mutableStateOf(CardId.Pro) }161 var deckOpen by remember { mutableStateOf(true) }162 var fanning by remember { mutableStateOf(false) }163 var celebrating by remember { mutableStateOf<CardId?>(null) }164165 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)166167 fun handleBack() {168 if (!deckOpen) deckOpen = true else onBack()169 }170 BackHandler { handleBack() }171172 173 fun celebrate() {174 if (fanning) return175 scope.launch {176 fanning = true177 active = CardId.Pro178 CELEBRATION_FAN.forEachIndexed { i, id ->179 delay(150L)180 active = id181 }182 delay(150L)183 active = CardId.Pro184 fanning = false185 celebrating = CardId.Pro186 187 188 delay(3200L)189 celebrating = null190 }191 }192193 PeachTheme(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 }337338 339 340 341 342 343 Text(344 "v${BuildConfig.VERSION_NAME}",345 color = peachColors.T2.copy(alpha = 0.5f),346 fontFamily = PeachType.Mono,347 fontSize = 9.sp,348 textAlign = TextAlign.Center,349 modifier = Modifier350 .align(Alignment.BottomCenter)351 .fillMaxWidth()352 353 354 .navigationBarsPadding()355 .padding(bottom = PeachSpacing.xs),356 )357 }358 }359 if (showCelebration) {360 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })361 }362 }363 }364}365366@Composable367private fun StackCard(368 spec: StackCardSpec,369 active: Boolean,370 focused: Boolean,371 lede: String,372 showSparkle: Boolean,373 celebrating: Boolean,374 onIconClick: (() -> Unit)?,375 body: @Composable () -> Unit,376 modifier: Modifier,377 onClick: () -> Unit,378 onCollapseRequest: () -> Unit,379 onExpandRequest: () -> Unit,380 enabled: Boolean,381) {382 val interaction = remember { MutableInteractionSource() }383 Box(384 modifier385 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)386 .clip(RoundedCornerShape(PeachStackColors.CardRadius))387 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }388 .clickable(389 interactionSource = interaction,390 indication = null,391 enabled = enabled && !active || (active && !focused),392 ) { onClick() }393 .padding(394 horizontal = if (active) 18.dp else 16.dp,395 vertical = if (active) 16.dp else 12.dp,396 ),397 ) {398 if (showSparkle) SparkleDust(Modifier.fillMaxSize())399 if (active) {400 Column(Modifier.fillMaxSize()) {401 Row(402 Modifier.fillMaxWidth(),403 horizontalArrangement = Arrangement.SpaceBetween,404 verticalAlignment = Alignment.CenterVertically,405 ) {406 Text(407 spec.num,408 color = spec.fg.copy(alpha = 0.7f),409 fontFamily = PeachType.Mono,410 fontSize = 8.5.sp,411 letterSpacing = 0.26.em,412 )413 Box(contentAlignment = Alignment.Center) {414 Box(415 Modifier.clip(RoundedCornerShape(999.dp))416 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)417 .padding(2.dp),418 ) {419 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))420 }421 422 423 424 if (celebrating) ProSparkBurst(color = spec.fg)425 }426 }427 Text(428 spec.title,429 modifier = Modifier.padding(top = 6.dp),430 color = spec.fg,431 fontFamily = PeachType.Display,432 fontStyle = FontStyle.Italic,433 fontSize = 26.sp,434 )435 Text(436 lede,437 modifier = Modifier.padding(top = 4.dp),438 color = spec.fg.copy(alpha = 0.72f),439 fontFamily = PeachType.Display,440 fontStyle = FontStyle.Italic,441 fontSize = 12.5.sp,442 )443 if (celebrating) ProThankYouFlash(color = spec.fg)444 445 446 447 448 449 450 451 452 453 454 455 val scrollState = rememberScrollState()456 val density = LocalDensity.current457 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }458 459 460 461 val latestFocused = rememberUpdatedState(focused)462 val latestActive = rememberUpdatedState(active)463 val nestedScrollConnection = remember(scrollState) {464 object : NestedScrollConnection {465 var expandAccum = 0f466 var collapseAccum = 0f467 468 469 470 471 472 473 474 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {475 if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {476 return Offset.Zero477 }478 expandAccum += available.y479 return available480 }481 override fun onPostScroll(482 consumed: Offset,483 available: Offset,484 source: NestedScrollSource,485 ): Offset {486 if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {487 return Offset.Zero488 }489 collapseAccum += available.y490 return Offset(0f, available.y)491 }492 override suspend fun onPreFling(available: Velocity): Velocity {493 494 495 496 497 498 if (expandAccum <= -gestureThresholdPx) {499 onExpandRequest()500 } else if (collapseAccum >= gestureThresholdPx) {501 onCollapseRequest()502 }503 expandAccum = 0f504 collapseAccum = 0f505 return Velocity.Zero506 }507 }508 }509 Column(510 Modifier.fillMaxWidth().weight(1f)511 .padding(top = 14.dp)512 .nestedScroll(nestedScrollConnection)513 .verticalScroll(scrollState),514 ) {515 body()516 517 518 519 Spacer(Modifier.height(PeachSpacing.xl))520 }521 }522 } else {523 524 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {525 Text(526 spec.title,527 color = spec.fg,528 fontFamily = PeachType.Display,529 fontStyle = FontStyle.Italic,530 fontWeight = FontWeight.Normal,531 fontSize = 19.sp,532 maxLines = 1,533 overflow = TextOverflow.Ellipsis,534 )535 }536 }537 }538}539540@Composable541private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {542 PeachTopBar(543 title = "Settings",544 leading = {545 Box(546 Modifier547 .size(40.dp)548 .clip(RoundedCornerShape(999.dp))549 .clickable(onClick = onBack),550 contentAlignment = Alignment.Center,551 ) {552 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)553 }554 },555 actions = {556 Text(557 "${activeIndex + 1} / $total",558 color = peachColors.T2,559 fontFamily = PeachType.Mono,560 fontSize = 9.sp,561 letterSpacing = 0.22.em,562 modifier = Modifier.padding(horizontal = PeachSpacing.sm),563 )564 },565 )566}567568569570571572573574private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {575 val a = Math.toRadians(155.0)576 val dx = kotlin.math.sin(a).toFloat()577 val dy = -kotlin.math.cos(a).toFloat()578 val cx = size.width / 2f579 val cy = size.height / 2f580 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f581 return Brush.linearGradient(582 colors = listOf(start, end),583 start = Offset(cx - dx * half, cy - dy * half),584 end = Offset(cx + dx * half, cy + dy * half),585 )586}587588private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)589590591592private val PRO_SPARKS = listOf(593 ProSpark(0f, -54f, 0, "✦"),594 ProSpark(38f, -38f, 50, "✦"),595 ProSpark(54f, 0f, 110, "·"),596 ProSpark(38f, 38f, 30, "✦"),597 ProSpark(0f, 54f, 80, "·"),598 ProSpark(-38f, 38f, 130, "✦"),599 ProSpark(-54f, 0f, 55, "·"),600 ProSpark(-38f, -38f, 95, "✦"),601)602603604605606607608609@Composable610private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {611 if (LocalReduceMotion.current) return612 Box(modifier) {613 PRO_SPARKS.forEach { spark ->614 val progress = remember(spark) { Animatable(0f) }615 LaunchedEffect(spark) {616 delay(spark.delayMs.toLong())617 progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))618 }619 val t = progress.value620 val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f621 val scale = 0.5f + 0.3f * t622 Text(623 spark.glyph,624 color = color.copy(alpha = alpha.coerceIn(0f, 1f)),625 fontSize = 9.sp,626 modifier = Modifier627 .align(Alignment.Center)628 .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)629 .graphicsLayer { scaleX = scale; scaleY = scale },630 )631 }632 }633}634635636637638639640@Composable641private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {642 val reduceMotion = LocalReduceMotion.current643 val alpha = remember { Animatable(0f) }644 LaunchedEffect(Unit) {645 if (reduceMotion) {646 alpha.snapTo(0.75f)647 } else {648 alpha.animateTo(649 0f,650 keyframes {651 durationMillis = 2500652 0f at 0653 0.75f at 375654 0.75f at 1875655 0f at 2500656 },657 )658 }659 }660 Text(661 "thank you ✦",662 modifier = modifier.padding(top = 4.dp),663 color = color.copy(alpha = alpha.value),664 fontFamily = PeachType.Display,665 fontStyle = FontStyle.Italic,666 fontSize = 11.sp,667 )668}669670671672673674675@Composable676private fun SparkleDust(modifier: Modifier = Modifier) {677 val reduceMotion = LocalReduceMotion.current678 val t = rememberInfiniteTransition(label = "dust")679 val drift by if (reduceMotion) {680 remember { mutableStateOf(0f) }681 } else {682 t.animateFloat(683 initialValue = 0f,684 targetValue = 1f,685 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),686 label = "drift",687 )688 }689 Box(690 modifier.drawWithCache {691 val tile = 48.dp.toPx()692 val dx = drift * tile693 onDrawBehind {694 val cols = (size.width / tile).toInt() + 2695 val rows = (size.height / tile).toInt() + 2696 for (r in 0..rows) {697 for (c in 0..cols) {698 val baseX = c * tile + dx699 val baseY = r * tile + dx700 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)701 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)702 }703 }704 }705 },706 )707}