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 PeachTheme(accent = accent, reduceMotion = reduceMotion) {193 ShellBackground {194 Column(195 Modifier196 .fillMaxSize()197 .statusBarsPadding()198 .padding(horizontal = PeachSpacing.md),199 ) {200 SettingsTopBar(201 activeIndex = activeIndex,202 total = STACK_CARDS.size,203 onBack = { handleBack() },204 )205 Spacer(Modifier.height(PeachSpacing.sm))206207 BoxWithConstraints(Modifier.fillMaxSize()) {208 val deckHeight = settingsDeckHeight(maxHeight)209 val focusHeight = deckHeight - 8.dp210 211 212 213 214 215 216 val expandedH = (217 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H218 ).coerceAtLeast(EXPANDED_H)219 Box(Modifier.fillMaxWidth().height(deckHeight)) {220 STACK_CARDS.forEachIndexed { index, card ->221 val isActive = card.id == active222 val distance = when {223 index == activeIndex -> -1224 index > activeIndex -> index - activeIndex - 1225 else -> STACK_CARDS.size - activeIndex + index - 1226 }227 val targetTop = when {228 isActive -> 0.dp229 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp230 else -> collapsedTabTop(distance, expandedH)231 }232 val targetHeight = when {233 isActive && deckOpen -> expandedH234 isActive -> focusHeight235 else -> TAB_CARD_H236 }237 val concealed = !deckOpen && !isActive238239 240 241 242 243 244 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)245 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)246 val top by animateDpAsState(247 targetTop,248 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,249 label = "top",250 )251 val cardH by animateDpAsState(252 targetHeight,253 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,254 label = "h",255 )256 val alpha by animateFloatAsState(257 if (concealed) 0f else 1f,258 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,259 label = "a",260 )261 val zIndex = if (isActive) 99f else (50 - distance).toFloat()262263 val density = LocalDensity.current264 StackCard(265 spec = card,266 active = isActive,267 focused = isActive && !deckOpen,268 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),269 showSparkle = card.id == CardId.Pro && isPro,270 celebrating = celebrating == card.id,271 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,272 body = {273 CardBody(274 id = card.id,275 fg = card.fg,276 vm = vm,277 settings = settings,278 entriesCount = entries.count { it.deletedAt == null },279 onNavigate = onNavigate,280 )281 },282 modifier = Modifier283 .fillMaxWidth()284 .align(Alignment.TopStart)285 .zIndex(zIndex)286 287 288 289 290 291 292 293 294 295 296 297 .then(298 if (!isActive) {299 Modifier.pointerInput(card.id, deckOpen) {300 var dragY = 0f301 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }302 detectDragGestures(303 onDragStart = { dragY = 0f },304 onDrag = { change, amount -> change.consume(); dragY += amount.y },305 onDragEnd = {306 if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {307 active = card.id308 deckOpen = false309 }310 },311 )312 }313 } else {314 Modifier315 },316 )317 .graphicsLayer {318 translationY = top.toPx()319 this.alpha = alpha320 }321 .height(cardH),322 onClick = {323 324 325 if (!isActive) {326 active = card.id327 deckOpen = false328 }329 },330 onCollapseRequest = { deckOpen = true },331 onExpandRequest = { active = card.id; deckOpen = false },332 enabled = !concealed,333 )334 }335 }336337 338 339 340 341 342 Text(343 "v${BuildConfig.VERSION_NAME}",344 color = peachColors.T2.copy(alpha = 0.5f),345 fontFamily = PeachType.Mono,346 fontSize = 9.sp,347 textAlign = TextAlign.Center,348 modifier = Modifier349 .align(Alignment.BottomCenter)350 .fillMaxWidth()351 352 353 .navigationBarsPadding()354 .padding(bottom = PeachSpacing.xs),355 )356 }357 }358 if (showCelebration) {359 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })360 }361 }362 }363}364365@Composable366private fun StackCard(367 spec: StackCardSpec,368 active: Boolean,369 focused: Boolean,370 lede: String,371 showSparkle: Boolean,372 celebrating: Boolean,373 onIconClick: (() -> Unit)?,374 body: @Composable () -> Unit,375 modifier: Modifier,376 onClick: () -> Unit,377 onCollapseRequest: () -> Unit,378 onExpandRequest: () -> Unit,379 enabled: Boolean,380) {381 val interaction = remember { MutableInteractionSource() }382 Box(383 modifier384 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)385 .clip(RoundedCornerShape(PeachStackColors.CardRadius))386 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }387 .clickable(388 interactionSource = interaction,389 indication = null,390 enabled = enabled && !active || (active && !focused),391 ) { onClick() }392 .padding(393 horizontal = if (active) 18.dp else 16.dp,394 vertical = if (active) 16.dp else 12.dp,395 ),396 ) {397 if (showSparkle) SparkleDust(Modifier.fillMaxSize())398 if (active) {399 Column(Modifier.fillMaxSize()) {400 Row(401 Modifier.fillMaxWidth(),402 horizontalArrangement = Arrangement.SpaceBetween,403 verticalAlignment = Alignment.CenterVertically,404 ) {405 Text(406 spec.num,407 color = spec.fg.copy(alpha = 0.7f),408 fontFamily = PeachType.Mono,409 fontSize = 8.5.sp,410 letterSpacing = 0.26.em,411 )412 Box(contentAlignment = Alignment.Center) {413 Box(414 Modifier.clip(RoundedCornerShape(999.dp))415 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)416 .padding(2.dp),417 ) {418 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))419 }420 421 422 423 if (celebrating) ProSparkBurst(color = spec.fg)424 }425 }426 Text(427 spec.title,428 modifier = Modifier.padding(top = 6.dp),429 color = spec.fg,430 fontFamily = PeachType.Display,431 fontStyle = FontStyle.Italic,432 fontSize = 26.sp,433 )434 Text(435 lede,436 modifier = Modifier.padding(top = 4.dp),437 color = spec.fg.copy(alpha = 0.72f),438 fontFamily = PeachType.Display,439 fontStyle = FontStyle.Italic,440 fontSize = 12.5.sp,441 )442 if (celebrating) ProThankYouFlash(color = spec.fg)443 444 445 446 447 448 449 450 451 452 453 454 val scrollState = rememberScrollState()455 val density = LocalDensity.current456 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }457 458 459 460 val latestFocused = rememberUpdatedState(focused)461 val latestActive = rememberUpdatedState(active)462 val nestedScrollConnection = remember(scrollState) {463 object : NestedScrollConnection {464 var expandAccum = 0f465 var collapseAccum = 0f466 467 468 469 470 471 472 473 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {474 if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {475 return Offset.Zero476 }477 expandAccum += available.y478 return available479 }480 override fun onPostScroll(481 consumed: Offset,482 available: Offset,483 source: NestedScrollSource,484 ): Offset {485 if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {486 return Offset.Zero487 }488 collapseAccum += available.y489 return Offset(0f, available.y)490 }491 override suspend fun onPreFling(available: Velocity): Velocity {492 493 494 495 496 497 if (expandAccum <= -gestureThresholdPx) {498 onExpandRequest()499 } else if (collapseAccum >= gestureThresholdPx) {500 onCollapseRequest()501 }502 expandAccum = 0f503 collapseAccum = 0f504 return Velocity.Zero505 }506 }507 }508 Column(509 Modifier.fillMaxWidth().weight(1f)510 .padding(top = 14.dp)511 .nestedScroll(nestedScrollConnection)512 .verticalScroll(scrollState),513 ) {514 body()515 516 517 518 Spacer(Modifier.height(PeachSpacing.xl))519 }520 }521 } else {522 523 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {524 Text(525 spec.title,526 color = spec.fg,527 fontFamily = PeachType.Display,528 fontStyle = FontStyle.Italic,529 fontWeight = FontWeight.Normal,530 fontSize = 19.sp,531 maxLines = 1,532 overflow = TextOverflow.Ellipsis,533 )534 }535 }536 }537}538539@Composable540private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {541 PeachTopBar(542 title = "Settings",543 leading = {544 Box(545 Modifier546 .size(40.dp)547 .clip(RoundedCornerShape(999.dp))548 .clickable(onClick = onBack),549 contentAlignment = Alignment.Center,550 ) {551 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)552 }553 },554 actions = {555 Text(556 "${activeIndex + 1} / $total",557 color = peachColors.T2,558 fontFamily = PeachType.Mono,559 fontSize = 9.sp,560 letterSpacing = 0.22.em,561 modifier = Modifier.padding(horizontal = PeachSpacing.sm),562 )563 },564 )565}566567568569570571572573private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {574 val a = Math.toRadians(155.0)575 val dx = kotlin.math.sin(a).toFloat()576 val dy = -kotlin.math.cos(a).toFloat()577 val cx = size.width / 2f578 val cy = size.height / 2f579 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f580 return Brush.linearGradient(581 colors = listOf(start, end),582 start = Offset(cx - dx * half, cy - dy * half),583 end = Offset(cx + dx * half, cy + dy * half),584 )585}586587private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)588589590591private val PRO_SPARKS = listOf(592 ProSpark(0f, -54f, 0, "✦"),593 ProSpark(38f, -38f, 50, "✦"),594 ProSpark(54f, 0f, 110, "·"),595 ProSpark(38f, 38f, 30, "✦"),596 ProSpark(0f, 54f, 80, "·"),597 ProSpark(-38f, 38f, 130, "✦"),598 ProSpark(-54f, 0f, 55, "·"),599 ProSpark(-38f, -38f, 95, "✦"),600)601602603604605606607608@Composable609private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {610 if (LocalReduceMotion.current) return611 Box(modifier) {612 PRO_SPARKS.forEach { spark ->613 val progress = remember(spark) { Animatable(0f) }614 LaunchedEffect(spark) {615 delay(spark.delayMs.toLong())616 progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))617 }618 val t = progress.value619 val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f620 val scale = 0.5f + 0.3f * t621 Text(622 spark.glyph,623 color = color.copy(alpha = alpha.coerceIn(0f, 1f)),624 fontSize = 9.sp,625 modifier = Modifier626 .align(Alignment.Center)627 .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)628 .graphicsLayer { scaleX = scale; scaleY = scale },629 )630 }631 }632}633634635636637638639@Composable640private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {641 val reduceMotion = LocalReduceMotion.current642 val alpha = remember { Animatable(0f) }643 LaunchedEffect(Unit) {644 if (reduceMotion) {645 alpha.snapTo(0.75f)646 } else {647 alpha.animateTo(648 0f,649 keyframes {650 durationMillis = 2500651 0f at 0652 0.75f at 375653 0.75f at 1875654 0f at 2500655 },656 )657 }658 }659 Text(660 "thank you ✦",661 modifier = modifier.padding(top = 4.dp),662 color = color.copy(alpha = alpha.value),663 fontFamily = PeachType.Display,664 fontStyle = FontStyle.Italic,665 fontSize = 11.sp,666 )667}668669670671672673674@Composable675private fun SparkleDust(modifier: Modifier = Modifier) {676 val reduceMotion = LocalReduceMotion.current677 val t = rememberInfiniteTransition(label = "dust")678 val drift by if (reduceMotion) {679 remember { mutableStateOf(0f) }680 } else {681 t.animateFloat(682 initialValue = 0f,683 targetValue = 1f,684 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),685 label = "drift",686 )687 }688 Box(689 modifier.drawWithCache {690 val tile = 48.dp.toPx()691 val dx = drift * tile692 onDrawBehind {693 val cols = (size.width / tile).toInt() + 2694 val rows = (size.height / tile).toInt() + 2695 for (r in 0..rows) {696 for (c in 0..cols) {697 val baseX = c * tile + dx698 val baseY = r * tile + dx699 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)700 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)701 }702 }703 }704 },705 )706}