1package com.peachpasswords.ui.settings23import androidx.activity.compose.BackHandler4import androidx.compose.animation.core.Animatable5import androidx.compose.animation.core.Spring6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.animation.core.animateFloat8import androidx.compose.animation.core.animateFloatAsState9import androidx.compose.animation.core.infiniteRepeatable10import androidx.compose.animation.core.keyframes11import androidx.compose.animation.core.rememberInfiniteTransition12import androidx.compose.animation.core.spring13import androidx.compose.animation.core.tween14import androidx.compose.foundation.background15import androidx.compose.foundation.clickable16import androidx.compose.foundation.gestures.detectDragGestures17import androidx.compose.foundation.interaction.MutableInteractionSource18import androidx.compose.foundation.layout.Arrangement19import androidx.compose.foundation.layout.Box20import androidx.compose.foundation.layout.BoxWithConstraints21import androidx.compose.foundation.layout.Column22import androidx.compose.foundation.layout.Row23import androidx.compose.foundation.layout.Spacer24import androidx.compose.foundation.layout.fillMaxSize25import androidx.compose.foundation.layout.fillMaxWidth26import androidx.compose.foundation.layout.height27import androidx.compose.foundation.layout.padding28import androidx.compose.foundation.layout.size29import androidx.compose.foundation.layout.statusBarsPadding30import androidx.compose.foundation.shape.RoundedCornerShape31import androidx.compose.foundation.rememberScrollState32import androidx.compose.foundation.verticalScroll33import androidx.compose.ui.input.nestedscroll.NestedScrollConnection34import androidx.compose.ui.input.nestedscroll.NestedScrollSource35import androidx.compose.ui.input.nestedscroll.nestedScroll36import androidx.compose.ui.unit.Velocity37import androidx.compose.runtime.Composable38import androidx.compose.runtime.LaunchedEffect39import androidx.compose.runtime.getValue40import androidx.compose.runtime.mutableStateOf41import androidx.compose.runtime.remember42import androidx.compose.runtime.rememberCoroutineScope43import androidx.compose.runtime.rememberUpdatedState44import androidx.compose.runtime.setValue45import androidx.compose.runtime.collectAsState46import androidx.compose.ui.Alignment47import androidx.compose.ui.Modifier48import androidx.compose.ui.draw.clip49import androidx.compose.ui.draw.drawWithCache50import androidx.compose.ui.draw.shadow51import androidx.compose.ui.draw.drawBehind52import androidx.compose.ui.geometry.Offset53import androidx.compose.ui.graphics.BlendMode54import androidx.compose.ui.graphics.Brush55import androidx.compose.ui.graphics.Color56import androidx.compose.ui.graphics.drawscope.DrawScope57import androidx.compose.ui.graphics.graphicsLayer58import androidx.compose.ui.input.pointer.pointerInput59import androidx.compose.ui.platform.LocalDensity60import androidx.compose.ui.unit.Dp61import androidx.compose.ui.text.font.FontStyle62import androidx.compose.ui.text.font.FontWeight63import androidx.compose.ui.text.style.TextOverflow64import androidx.compose.ui.unit.dp65import androidx.compose.ui.unit.em66import androidx.compose.ui.unit.sp67import androidx.compose.ui.zIndex68import androidx.hilt.navigation.compose.hiltViewModel69import androidx.compose.material3.Text70import com.peachpasswords.ui.components.PeachTopBar71import com.peachpasswords.ui.theme.LocalPeachAccent72import com.peachpasswords.ui.theme.LocalReduceMotion73import com.peachpasswords.ui.theme.PeachSpacing74import com.peachpasswords.ui.theme.peachColors75import com.peachpasswords.ui.theme.PeachMotion76import com.peachpasswords.ui.theme.PeachTheme77import com.peachpasswords.ui.theme.PeachStackColors78import com.peachpasswords.ui.theme.PeachType79import com.peachpasswords.ui.theme.ShellBackground80import kotlinx.coroutines.delay81import kotlinx.coroutines.launch8283848586private val EXPANDED_H = 258.dp87private val TAB_H = 46.dp88private val TAB_OVERLAP = 14.dp89private val TAB_CARD_H = TAB_H + 4.dp90private val MAX_DECK_H = 462.dp91private val DECK_BOTTOM_GUTTER = 36.dp929394private val DRAG_THRESHOLD = 60.dp959697internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }9899100101102103104internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {105 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND106 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE107 else -> DragOutcome.NONE108}109110111112113114115116private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP117118119120121122123124internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =125 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance126127128129130131132133134135internal fun settingsDeckHeight(availableHeight: Dp): Dp {136 val constrained = availableHeight - DECK_BOTTOM_GUTTER137 return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)138}139140@Composable141fun SettingsScreen(142 onBack: () -> Unit,143 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},144 vm: SettingsViewModel = hiltViewModel(),145) {146 val entries by vm.entries.collectAsState()147 val settings by vm.settings.collectAsState()148 val isPro by vm.isPro.collectAsState()149 val showCelebration by vm.showCelebration.collectAsState()150 val reduceMotion = LocalReduceMotion.current151 val accent = LocalPeachAccent.current152 val scope = rememberCoroutineScope()153154 var active by remember { mutableStateOf(CardId.Pro) }155 var deckOpen by remember { mutableStateOf(true) }156 var fanning by remember { mutableStateOf(false) }157 var celebrating by remember { mutableStateOf<CardId?>(null) }158159 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)160161 fun handleBack() {162 if (!deckOpen) deckOpen = true else onBack()163 }164 BackHandler { handleBack() }165166 167 fun celebrate() {168 if (fanning) return169 scope.launch {170 fanning = true171 active = CardId.Pro172 CELEBRATION_FAN.forEachIndexed { i, id ->173 delay(150L)174 active = id175 }176 delay(150L)177 active = CardId.Pro178 fanning = false179 celebrating = CardId.Pro180 delay(900L)181 celebrating = null182 }183 }184185 186 187 188 189 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {190 ShellBackground {191 Column(192 Modifier193 .fillMaxSize()194 .statusBarsPadding()195 .padding(horizontal = PeachSpacing.md),196 ) {197 SettingsTopBar(198 activeIndex = activeIndex,199 total = STACK_CARDS.size,200 onBack = { handleBack() },201 )202 Spacer(Modifier.height(PeachSpacing.sm))203204 BoxWithConstraints(Modifier.fillMaxSize()) {205 val deckHeight = settingsDeckHeight(maxHeight)206 val focusHeight = deckHeight - 8.dp207 208 209 210 211 212 213 val expandedH = (214 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H215 ).coerceAtLeast(EXPANDED_H)216 Box(Modifier.fillMaxWidth().height(deckHeight)) {217 STACK_CARDS.forEachIndexed { index, card ->218 val isActive = card.id == active219 val distance = when {220 index == activeIndex -> -1221 index > activeIndex -> index - activeIndex - 1222 else -> STACK_CARDS.size - activeIndex + index - 1223 }224 val targetTop = when {225 isActive -> 0.dp226 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp227 else -> collapsedTabTop(distance, expandedH)228 }229 val targetHeight = when {230 isActive && deckOpen -> expandedH231 isActive -> focusHeight232 else -> TAB_CARD_H233 }234 val concealed = !deckOpen && !isActive235236 237 238 239 240 241 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)242 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)243 val top by animateDpAsState(244 targetTop,245 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,246 label = "top",247 )248 val cardH by animateDpAsState(249 targetHeight,250 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,251 label = "h",252 )253 val alpha by animateFloatAsState(254 if (concealed) 0f else 1f,255 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,256 label = "a",257 )258 val zIndex = if (isActive) 99f else (50 - distance).toFloat()259260 261 val shakeDy = remember { Animatable(0f) }262 val shakeRot = remember { Animatable(0f) }263 LaunchedEffect(celebrating == card.id) {264 if (celebrating == card.id && !reduceMotion) {265 launch {266 shakeRot.animateTo(0f, keyframes {267 durationMillis = 880268 0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757269 })270 }271 shakeDy.animateTo(0f, keyframes {272 durationMillis = 880273 0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757274 })275 }276 }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 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,286 body = {287 CardBody(288 id = card.id,289 fg = card.fg,290 vm = vm,291 settings = settings,292 entriesCount = entries.count { it.deletedAt == null },293 onNavigate = onNavigate,294 )295 },296 modifier = Modifier297 .fillMaxWidth()298 .align(Alignment.TopStart)299 .zIndex(zIndex)300 301 302 303 304 305 306 307 308 309 310 311 .then(312 if (!isActive) {313 Modifier.pointerInput(card.id, deckOpen) {314 var dragY = 0f315 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }316 detectDragGestures(317 onDragStart = { dragY = 0f },318 onDrag = { change, amount -> change.consume(); dragY += amount.y },319 onDragEnd = {320 if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {321 active = card.id322 deckOpen = false323 }324 },325 )326 }327 } else {328 Modifier329 },330 )331 .graphicsLayer {332 translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }333 rotationZ = shakeRot.value334 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 }351 }352 }353 if (showCelebration) {354 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })355 }356 }357 }358}359360@Composable361private fun StackCard(362 spec: StackCardSpec,363 active: Boolean,364 focused: Boolean,365 lede: String,366 showSparkle: Boolean,367 onIconClick: (() -> Unit)?,368 body: @Composable () -> Unit,369 modifier: Modifier,370 onClick: () -> Unit,371 onCollapseRequest: () -> Unit,372 onExpandRequest: () -> Unit,373 enabled: Boolean,374) {375 val interaction = remember { MutableInteractionSource() }376 Box(377 modifier378 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)379 .clip(RoundedCornerShape(PeachStackColors.CardRadius))380 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }381 .clickable(382 interactionSource = interaction,383 indication = null,384 enabled = enabled && !active || (active && !focused),385 ) { onClick() }386 .padding(387 horizontal = if (active) 18.dp else 16.dp,388 vertical = if (active) 16.dp else 12.dp,389 ),390 ) {391 if (showSparkle) SparkleDust(Modifier.fillMaxSize())392 if (active) {393 Column(Modifier.fillMaxSize()) {394 Row(395 Modifier.fillMaxWidth(),396 horizontalArrangement = Arrangement.SpaceBetween,397 verticalAlignment = Alignment.CenterVertically,398 ) {399 Text(400 spec.num,401 color = spec.fg.copy(alpha = 0.7f),402 fontFamily = PeachType.Mono,403 fontSize = 8.5.sp,404 letterSpacing = 0.26.em,405 )406 Box(407 Modifier.clip(RoundedCornerShape(999.dp))408 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)409 .padding(2.dp),410 ) {411 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))412 }413 }414 Text(415 spec.title,416 modifier = Modifier.padding(top = 6.dp),417 color = spec.fg,418 fontFamily = PeachType.Display,419 fontStyle = FontStyle.Italic,420 fontSize = 26.sp,421 )422 Text(423 lede,424 modifier = Modifier.padding(top = 4.dp),425 color = spec.fg.copy(alpha = 0.72f),426 fontFamily = PeachType.Display,427 fontStyle = FontStyle.Italic,428 fontSize = 12.5.sp,429 )430 431 432 433 434 435 436 437 438 439 440 441 val scrollState = rememberScrollState()442 val density = LocalDensity.current443 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }444 445 446 447 val latestFocused = rememberUpdatedState(focused)448 val latestActive = rememberUpdatedState(active)449 val nestedScrollConnection = remember(scrollState) {450 object : NestedScrollConnection {451 var expandAccum = 0f452 var collapseAccum = 0f453 454 455 456 457 458 459 460 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {461 if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {462 return Offset.Zero463 }464 expandAccum += available.y465 return available466 }467 override fun onPostScroll(468 consumed: Offset,469 available: Offset,470 source: NestedScrollSource,471 ): Offset {472 if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {473 return Offset.Zero474 }475 collapseAccum += available.y476 return Offset(0f, available.y)477 }478 override suspend fun onPreFling(available: Velocity): Velocity {479 480 481 482 483 484 if (expandAccum <= -gestureThresholdPx) {485 onExpandRequest()486 } else if (collapseAccum >= gestureThresholdPx) {487 onCollapseRequest()488 }489 expandAccum = 0f490 collapseAccum = 0f491 return Velocity.Zero492 }493 }494 }495 Column(496 Modifier.fillMaxWidth().weight(1f)497 .padding(top = 14.dp)498 .nestedScroll(nestedScrollConnection)499 .verticalScroll(scrollState),500 ) {501 body()502 503 504 505 Spacer(Modifier.height(PeachSpacing.xl))506 }507 }508 } else {509 510 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {511 Text(512 spec.title,513 color = spec.fg,514 fontFamily = PeachType.Display,515 fontStyle = FontStyle.Italic,516 fontWeight = FontWeight.Normal,517 fontSize = 19.sp,518 maxLines = 1,519 overflow = TextOverflow.Ellipsis,520 )521 }522 }523 }524}525526@Composable527private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {528 PeachTopBar(529 title = "Settings",530 leading = {531 Box(532 Modifier533 .size(40.dp)534 .clip(RoundedCornerShape(999.dp))535 .clickable(onClick = onBack),536 contentAlignment = Alignment.Center,537 ) {538 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)539 }540 },541 actions = {542 Text(543 "${activeIndex + 1} / $total",544 color = peachColors.T2,545 fontFamily = PeachType.Mono,546 fontSize = 9.sp,547 letterSpacing = 0.22.em,548 modifier = Modifier.padding(horizontal = PeachSpacing.sm),549 )550 },551 )552}553554555556557558559560private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {561 val a = Math.toRadians(155.0)562 val dx = kotlin.math.sin(a).toFloat()563 val dy = -kotlin.math.cos(a).toFloat()564 val cx = size.width / 2f565 val cy = size.height / 2f566 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f567 return Brush.linearGradient(568 colors = listOf(start, end),569 start = Offset(cx - dx * half, cy - dy * half),570 end = Offset(cx + dx * half, cy + dy * half),571 )572}573574575576577578579@Composable580private fun SparkleDust(modifier: Modifier = Modifier) {581 val reduceMotion = LocalReduceMotion.current582 val t = rememberInfiniteTransition(label = "dust")583 val drift by if (reduceMotion) {584 remember { mutableStateOf(0f) }585 } else {586 t.animateFloat(587 initialValue = 0f,588 targetValue = 1f,589 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),590 label = "drift",591 )592 }593 Box(594 modifier.drawWithCache {595 val tile = 48.dp.toPx()596 val dx = drift * tile597 onDrawBehind {598 val cols = (size.width / tile).toInt() + 2599 val rows = (size.height / tile).toInt() + 2600 for (r in 0..rows) {601 for (c in 0..cols) {602 val baseX = c * tile + dx603 val baseY = r * tile + dx604 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)605 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)606 }607 }608 }609 },610 )611}