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 MAX_DECK_H = 462.dp96private val DECK_BOTTOM_GUTTER = 36.dp979899private val DRAG_THRESHOLD = 60.dp100101102internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }103104105106107108109internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {110 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND111 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE112 else -> DragOutcome.NONE113}114115116117118119120121private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP122123124125126127128129internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =130 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance131132133134135136137138139140internal fun settingsDeckHeight(availableHeight: Dp): Dp {141 val constrained = availableHeight - DECK_BOTTOM_GUTTER142 return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)143}144145@Composable146fun SettingsScreen(147 onBack: () -> Unit,148 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},149 vm: SettingsViewModel = hiltViewModel(),150) {151 val entries by vm.entries.collectAsState()152 val settings by vm.settings.collectAsState()153 val isPro by vm.isPro.collectAsState()154 val showCelebration by vm.showCelebration.collectAsState()155 val reduceMotion = LocalReduceMotion.current156 val accent = LocalPeachAccent.current157 val scope = rememberCoroutineScope()158159 var active by remember { mutableStateOf(CardId.Pro) }160 var deckOpen by remember { mutableStateOf(true) }161 var fanning by remember { mutableStateOf(false) }162 var celebrating by remember { mutableStateOf<CardId?>(null) }163164 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)165166 fun handleBack() {167 if (!deckOpen) deckOpen = true else onBack()168 }169 BackHandler { handleBack() }170171 172 fun celebrate() {173 if (fanning) return174 scope.launch {175 fanning = true176 active = CardId.Pro177 CELEBRATION_FAN.forEachIndexed { i, id ->178 delay(150L)179 active = id180 }181 delay(150L)182 active = CardId.Pro183 fanning = false184 celebrating = CardId.Pro185 186 187 delay(3200L)188 celebrating = null189 }190 }191192 193 194 195 196 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {197 ShellBackground {198 Column(199 Modifier200 .fillMaxSize()201 .statusBarsPadding()202 .padding(horizontal = PeachSpacing.md),203 ) {204 SettingsTopBar(205 activeIndex = activeIndex,206 total = STACK_CARDS.size,207 onBack = { handleBack() },208 )209 Spacer(Modifier.height(PeachSpacing.sm))210211 BoxWithConstraints(Modifier.fillMaxSize()) {212 val deckHeight = settingsDeckHeight(maxHeight)213 val focusHeight = deckHeight - 8.dp214 215 216 217 218 219 220 val expandedH = (221 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H222 ).coerceAtLeast(EXPANDED_H)223 Box(Modifier.fillMaxWidth().height(deckHeight)) {224 STACK_CARDS.forEachIndexed { index, card ->225 val isActive = card.id == active226 val distance = when {227 index == activeIndex -> -1228 index > activeIndex -> index - activeIndex - 1229 else -> STACK_CARDS.size - activeIndex + index - 1230 }231 val targetTop = when {232 isActive -> 0.dp233 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp234 else -> collapsedTabTop(distance, expandedH)235 }236 val targetHeight = when {237 isActive && deckOpen -> expandedH238 isActive -> focusHeight239 else -> TAB_CARD_H240 }241 val concealed = !deckOpen && !isActive242243 244 245 246 247 248 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)249 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)250 val top by animateDpAsState(251 targetTop,252 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,253 label = "top",254 )255 val cardH by animateDpAsState(256 targetHeight,257 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,258 label = "h",259 )260 val alpha by animateFloatAsState(261 if (concealed) 0f else 1f,262 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,263 label = "a",264 )265 val zIndex = if (isActive) 99f else (50 - distance).toFloat()266267 val density = LocalDensity.current268 StackCard(269 spec = card,270 active = isActive,271 focused = isActive && !deckOpen,272 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),273 showSparkle = card.id == CardId.Pro && isPro,274 celebrating = celebrating == card.id,275 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,276 body = {277 CardBody(278 id = card.id,279 fg = card.fg,280 vm = vm,281 settings = settings,282 entriesCount = entries.count { it.deletedAt == null },283 onNavigate = onNavigate,284 )285 },286 modifier = Modifier287 .fillMaxWidth()288 .align(Alignment.TopStart)289 .zIndex(zIndex)290 291 292 293 294 295 296 297 298 299 300 301 .then(302 if (!isActive) {303 Modifier.pointerInput(card.id, deckOpen) {304 var dragY = 0f305 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }306 detectDragGestures(307 onDragStart = { dragY = 0f },308 onDrag = { change, amount -> change.consume(); dragY += amount.y },309 onDragEnd = {310 if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {311 active = card.id312 deckOpen = false313 }314 },315 )316 }317 } else {318 Modifier319 },320 )321 .graphicsLayer {322 translationY = top.toPx()323 this.alpha = alpha324 }325 .height(cardH),326 onClick = {327 328 329 if (!isActive) {330 active = card.id331 deckOpen = false332 }333 },334 onCollapseRequest = { deckOpen = true },335 onExpandRequest = { active = card.id; deckOpen = false },336 enabled = !concealed,337 )338 }339 }340341 342 343 344 345 346 Text(347 "v${BuildConfig.VERSION_NAME}",348 color = peachColors.T2.copy(alpha = 0.5f),349 fontFamily = PeachType.Mono,350 fontSize = 9.sp,351 textAlign = TextAlign.Center,352 modifier = Modifier353 .align(Alignment.BottomCenter)354 .fillMaxWidth()355 356 357 .navigationBarsPadding()358 .padding(bottom = PeachSpacing.xs),359 )360 }361 }362 if (showCelebration) {363 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })364 }365 }366 }367}368369@Composable370private fun StackCard(371 spec: StackCardSpec,372 active: Boolean,373 focused: Boolean,374 lede: String,375 showSparkle: Boolean,376 celebrating: Boolean,377 onIconClick: (() -> Unit)?,378 body: @Composable () -> Unit,379 modifier: Modifier,380 onClick: () -> Unit,381 onCollapseRequest: () -> Unit,382 onExpandRequest: () -> Unit,383 enabled: Boolean,384) {385 val interaction = remember { MutableInteractionSource() }386 Box(387 modifier388 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)389 .clip(RoundedCornerShape(PeachStackColors.CardRadius))390 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }391 .clickable(392 interactionSource = interaction,393 indication = null,394 enabled = enabled && !active || (active && !focused),395 ) { onClick() }396 .padding(397 horizontal = if (active) 18.dp else 16.dp,398 vertical = if (active) 16.dp else 12.dp,399 ),400 ) {401 if (showSparkle) SparkleDust(Modifier.fillMaxSize())402 if (active) {403 Column(Modifier.fillMaxSize()) {404 Row(405 Modifier.fillMaxWidth(),406 horizontalArrangement = Arrangement.SpaceBetween,407 verticalAlignment = Alignment.CenterVertically,408 ) {409 Text(410 spec.num,411 color = spec.fg.copy(alpha = 0.7f),412 fontFamily = PeachType.Mono,413 fontSize = 8.5.sp,414 letterSpacing = 0.26.em,415 )416 Box(contentAlignment = Alignment.Center) {417 Box(418 Modifier.clip(RoundedCornerShape(999.dp))419 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)420 .padding(2.dp),421 ) {422 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))423 }424 425 426 427 if (celebrating) ProSparkBurst(color = spec.fg)428 }429 }430 Text(431 spec.title,432 modifier = Modifier.padding(top = 6.dp),433 color = spec.fg,434 fontFamily = PeachType.Display,435 fontStyle = FontStyle.Italic,436 fontSize = 26.sp,437 )438 Text(439 lede,440 modifier = Modifier.padding(top = 4.dp),441 color = spec.fg.copy(alpha = 0.72f),442 fontFamily = PeachType.Display,443 fontStyle = FontStyle.Italic,444 fontSize = 12.5.sp,445 )446 if (celebrating) ProThankYouFlash(color = spec.fg)447 448 449 450 451 452 453 454 455 456 457 458 val scrollState = rememberScrollState()459 val density = LocalDensity.current460 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }461 462 463 464 val latestFocused = rememberUpdatedState(focused)465 val latestActive = rememberUpdatedState(active)466 val nestedScrollConnection = remember(scrollState) {467 object : NestedScrollConnection {468 var expandAccum = 0f469 var collapseAccum = 0f470 471 472 473 474 475 476 477 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {478 if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {479 return Offset.Zero480 }481 expandAccum += available.y482 return available483 }484 override fun onPostScroll(485 consumed: Offset,486 available: Offset,487 source: NestedScrollSource,488 ): Offset {489 if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {490 return Offset.Zero491 }492 collapseAccum += available.y493 return Offset(0f, available.y)494 }495 override suspend fun onPreFling(available: Velocity): Velocity {496 497 498 499 500 501 if (expandAccum <= -gestureThresholdPx) {502 onExpandRequest()503 } else if (collapseAccum >= gestureThresholdPx) {504 onCollapseRequest()505 }506 expandAccum = 0f507 collapseAccum = 0f508 return Velocity.Zero509 }510 }511 }512 Column(513 Modifier.fillMaxWidth().weight(1f)514 .padding(top = 14.dp)515 .nestedScroll(nestedScrollConnection)516 .verticalScroll(scrollState),517 ) {518 body()519 520 521 522 Spacer(Modifier.height(PeachSpacing.xl))523 }524 }525 } else {526 527 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {528 Text(529 spec.title,530 color = spec.fg,531 fontFamily = PeachType.Display,532 fontStyle = FontStyle.Italic,533 fontWeight = FontWeight.Normal,534 fontSize = 19.sp,535 maxLines = 1,536 overflow = TextOverflow.Ellipsis,537 )538 }539 }540 }541}542543@Composable544private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {545 PeachTopBar(546 title = "Settings",547 leading = {548 Box(549 Modifier550 .size(40.dp)551 .clip(RoundedCornerShape(999.dp))552 .clickable(onClick = onBack),553 contentAlignment = Alignment.Center,554 ) {555 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)556 }557 },558 actions = {559 Text(560 "${activeIndex + 1} / $total",561 color = peachColors.T2,562 fontFamily = PeachType.Mono,563 fontSize = 9.sp,564 letterSpacing = 0.22.em,565 modifier = Modifier.padding(horizontal = PeachSpacing.sm),566 )567 },568 )569}570571572573574575576577private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {578 val a = Math.toRadians(155.0)579 val dx = kotlin.math.sin(a).toFloat()580 val dy = -kotlin.math.cos(a).toFloat()581 val cx = size.width / 2f582 val cy = size.height / 2f583 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f584 return Brush.linearGradient(585 colors = listOf(start, end),586 start = Offset(cx - dx * half, cy - dy * half),587 end = Offset(cx + dx * half, cy + dy * half),588 )589}590591private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)592593594595private val PRO_SPARKS = listOf(596 ProSpark(0f, -54f, 0, "✦"),597 ProSpark(38f, -38f, 50, "✦"),598 ProSpark(54f, 0f, 110, "·"),599 ProSpark(38f, 38f, 30, "✦"),600 ProSpark(0f, 54f, 80, "·"),601 ProSpark(-38f, 38f, 130, "✦"),602 ProSpark(-54f, 0f, 55, "·"),603 ProSpark(-38f, -38f, 95, "✦"),604)605606607608609610611612@Composable613private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {614 if (LocalReduceMotion.current) return615 Box(modifier) {616 PRO_SPARKS.forEach { spark ->617 val progress = remember(spark) { Animatable(0f) }618 LaunchedEffect(spark) {619 delay(spark.delayMs.toLong())620 progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))621 }622 val t = progress.value623 val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f624 val scale = 0.5f + 0.3f * t625 Text(626 spark.glyph,627 color = color.copy(alpha = alpha.coerceIn(0f, 1f)),628 fontSize = 9.sp,629 modifier = Modifier630 .align(Alignment.Center)631 .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)632 .graphicsLayer { scaleX = scale; scaleY = scale },633 )634 }635 }636}637638639640641642643@Composable644private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {645 val reduceMotion = LocalReduceMotion.current646 val alpha = remember { Animatable(0f) }647 LaunchedEffect(Unit) {648 if (reduceMotion) {649 alpha.snapTo(0.75f)650 } else {651 alpha.animateTo(652 0f,653 keyframes {654 durationMillis = 2500655 0f at 0656 0.75f at 375657 0.75f at 1875658 0f at 2500659 },660 )661 }662 }663 Text(664 "thank you ✦",665 modifier = modifier.padding(top = 4.dp),666 color = color.copy(alpha = alpha.value),667 fontFamily = PeachType.Display,668 fontStyle = FontStyle.Italic,669 fontSize = 11.sp,670 )671}672673674675676677678@Composable679private fun SparkleDust(modifier: Modifier = Modifier) {680 val reduceMotion = LocalReduceMotion.current681 val t = rememberInfiniteTransition(label = "dust")682 val drift by if (reduceMotion) {683 remember { mutableStateOf(0f) }684 } else {685 t.animateFloat(686 initialValue = 0f,687 targetValue = 1f,688 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),689 label = "drift",690 )691 }692 Box(693 modifier.drawWithCache {694 val tile = 48.dp.toPx()695 val dx = drift * tile696 onDrawBehind {697 val cols = (size.width / tile).toInt() + 2698 val rows = (size.height / tile).toInt() + 2699 for (r in 0..rows) {700 for (c in 0..cols) {701 val baseX = c * tile + dx702 val baseY = r * tile + dx703 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)704 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)705 }706 }707 }708 },709 )710}