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 .pointerInput(card.id, deckOpen, isActive) {303 var dragY = 0f304 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }305 detectDragGestures(306 onDragStart = { dragY = 0f },307 onDrag = { change, amount -> change.consume(); dragY += amount.y },308 onDragEnd = {309 when (resolveStackDrag(dragY, thresholdPx, isActive && !deckOpen)) {310 DragOutcome.EXPAND -> { active = card.id; deckOpen = false }311 DragOutcome.COLLAPSE -> { deckOpen = true }312 DragOutcome.NONE -> {}313 }314 },315 )316 }317 .graphicsLayer {318 translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }319 rotationZ = shakeRot.value320 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 onIconClick: (() -> Unit)?,354 body: @Composable () -> Unit,355 modifier: Modifier,356 onClick: () -> Unit,357 onCollapseRequest: () -> Unit,358 onExpandRequest: () -> Unit,359 enabled: Boolean,360) {361 val interaction = remember { MutableInteractionSource() }362 Box(363 modifier364 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)365 .clip(RoundedCornerShape(PeachStackColors.CardRadius))366 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }367 .clickable(368 interactionSource = interaction,369 indication = null,370 enabled = enabled && !active || (active && !focused),371 ) { onClick() }372 .padding(373 horizontal = if (active) 18.dp else 16.dp,374 vertical = if (active) 16.dp else 12.dp,375 ),376 ) {377 if (showSparkle) SparkleDust(Modifier.fillMaxSize())378 if (active) {379 Column(Modifier.fillMaxSize()) {380 Row(381 Modifier.fillMaxWidth(),382 horizontalArrangement = Arrangement.SpaceBetween,383 verticalAlignment = Alignment.CenterVertically,384 ) {385 Text(386 spec.num,387 color = spec.fg.copy(alpha = 0.7f),388 fontFamily = PeachType.Mono,389 fontSize = 8.5.sp,390 letterSpacing = 0.26.em,391 )392 Box(393 Modifier.clip(RoundedCornerShape(999.dp))394 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)395 .padding(2.dp),396 ) {397 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))398 }399 }400 Text(401 spec.title,402 modifier = Modifier.padding(top = 6.dp),403 color = spec.fg,404 fontFamily = PeachType.Display,405 fontStyle = FontStyle.Italic,406 fontSize = 26.sp,407 )408 Text(409 lede,410 modifier = Modifier.padding(top = 4.dp),411 color = spec.fg.copy(alpha = 0.72f),412 fontFamily = PeachType.Display,413 fontStyle = FontStyle.Italic,414 fontSize = 12.5.sp,415 )416 417 418 419 420 421 422 423 424 425 val scrollState = rememberScrollState()426 val density = LocalDensity.current427 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }428 429 430 431 432 433 val latestFocused = rememberUpdatedState(focused)434 val latestActive = rememberUpdatedState(active)435 val nestedScrollConnection = remember(scrollState) {436 object : NestedScrollConnection {437 var expandAccum = 0f438 var collapseAccum = 0f439 440 441 442 443 444 445 var latched = false446 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {447 val focused = latestFocused.value448 val active = latestActive.value449 if (latched || !active || focused || source != NestedScrollSource.Drag || available.y == 0f) {450 expandAccum = 0f451 return Offset.Zero452 }453 expandAccum += available.y454 if (kotlin.math.abs(expandAccum) >= gestureThresholdPx) {455 expandAccum = 0f456 latched = true457 onExpandRequest()458 }459 return available460 }461 override fun onPostScroll(462 consumed: Offset,463 available: Offset,464 source: NestedScrollSource,465 ): Offset {466 val focused = latestFocused.value467 if (latched || !focused || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {468 collapseAccum = 0f469 return Offset.Zero470 }471 collapseAccum += available.y472 if (collapseAccum >= gestureThresholdPx) {473 collapseAccum = 0f474 latched = true475 onCollapseRequest()476 }477 return Offset(0f, available.y)478 }479 override suspend fun onPreFling(available: Velocity): Velocity {480 latched = false481 return Velocity.Zero482 }483 }484 }485 Column(486 Modifier.fillMaxWidth().weight(1f)487 .padding(top = 14.dp)488 .nestedScroll(nestedScrollConnection)489 .verticalScroll(scrollState),490 ) {491 body()492 493 494 495 Spacer(Modifier.height(PeachSpacing.xl))496 }497 }498 } else {499 500 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {501 Text(502 spec.title,503 color = spec.fg,504 fontFamily = PeachType.Display,505 fontStyle = FontStyle.Italic,506 fontWeight = FontWeight.Normal,507 fontSize = 19.sp,508 maxLines = 1,509 overflow = TextOverflow.Ellipsis,510 )511 }512 }513 }514}515516@Composable517private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {518 PeachTopBar(519 title = "Settings",520 leading = {521 Box(522 Modifier523 .size(40.dp)524 .clip(RoundedCornerShape(999.dp))525 .clickable(onClick = onBack),526 contentAlignment = Alignment.Center,527 ) {528 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)529 }530 },531 actions = {532 Text(533 "${activeIndex + 1} / $total",534 color = peachColors.T2,535 fontFamily = PeachType.Mono,536 fontSize = 9.sp,537 letterSpacing = 0.22.em,538 modifier = Modifier.padding(horizontal = PeachSpacing.sm),539 )540 },541 )542}543544545546547548549550private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {551 val a = Math.toRadians(155.0)552 val dx = kotlin.math.sin(a).toFloat()553 val dy = -kotlin.math.cos(a).toFloat()554 val cx = size.width / 2f555 val cy = size.height / 2f556 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f557 return Brush.linearGradient(558 colors = listOf(start, end),559 start = Offset(cx - dx * half, cy - dy * half),560 end = Offset(cx + dx * half, cy + dy * half),561 )562}563564565566567568569@Composable570private fun SparkleDust(modifier: Modifier = Modifier) {571 val reduceMotion = LocalReduceMotion.current572 val t = rememberInfiniteTransition(label = "dust")573 val drift by if (reduceMotion) {574 remember { mutableStateOf(0f) }575 } else {576 t.animateFloat(577 initialValue = 0f,578 targetValue = 1f,579 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),580 label = "drift",581 )582 }583 Box(584 modifier.drawWithCache {585 val tile = 48.dp.toPx()586 val dx = drift * tile587 onDrawBehind {588 val cols = (size.width / tile).toInt() + 2589 val rows = (size.height / tile).toInt() + 2590 for (r in 0..rows) {591 for (c in 0..cols) {592 val baseX = c * tile + dx593 val baseY = r * tile + dx594 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)595 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)596 }597 }598 }599 },600 )601}