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.runtime.Composable34import androidx.compose.runtime.LaunchedEffect35import androidx.compose.runtime.getValue36import androidx.compose.runtime.mutableStateOf37import androidx.compose.runtime.remember38import androidx.compose.runtime.rememberCoroutineScope39import androidx.compose.runtime.setValue40import androidx.compose.runtime.collectAsState41import androidx.compose.ui.Alignment42import androidx.compose.ui.Modifier43import androidx.compose.ui.draw.clip44import androidx.compose.ui.draw.drawWithCache45import androidx.compose.ui.draw.shadow46import androidx.compose.ui.draw.drawBehind47import androidx.compose.ui.geometry.Offset48import androidx.compose.ui.graphics.BlendMode49import androidx.compose.ui.graphics.Brush50import androidx.compose.ui.graphics.Color51import androidx.compose.ui.graphics.drawscope.DrawScope52import androidx.compose.ui.graphics.graphicsLayer53import androidx.compose.ui.input.pointer.pointerInput54import androidx.compose.ui.platform.LocalDensity55import androidx.compose.ui.unit.Dp56import androidx.compose.ui.text.font.FontStyle57import androidx.compose.ui.text.font.FontWeight58import androidx.compose.ui.text.style.TextOverflow59import androidx.compose.ui.unit.dp60import androidx.compose.ui.unit.em61import androidx.compose.ui.unit.sp62import androidx.compose.ui.zIndex63import androidx.hilt.navigation.compose.hiltViewModel64import androidx.compose.material3.Text65import com.peachpasswords.ui.components.PeachTopBar66import com.peachpasswords.ui.theme.LocalPeachAccent67import com.peachpasswords.ui.theme.LocalReduceMotion68import com.peachpasswords.ui.theme.PeachSpacing69import com.peachpasswords.ui.theme.peachColors70import com.peachpasswords.ui.theme.PeachMotion71import com.peachpasswords.ui.theme.PeachTheme72import com.peachpasswords.ui.theme.PeachStackColors73import com.peachpasswords.ui.theme.PeachType74import com.peachpasswords.ui.theme.ShellBackground75import kotlinx.coroutines.delay76import kotlinx.coroutines.launch7778798081private val EXPANDED_H = 258.dp82private val TAB_H = 46.dp83private val TAB_OVERLAP = 14.dp84private val TAB_CARD_H = TAB_H + 4.dp85private val MAX_DECK_H = 462.dp86private val DECK_BOTTOM_GUTTER = 36.dp878889private val DRAG_THRESHOLD = 60.dp909192internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }93949596979899internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {100 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND101 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE102 else -> DragOutcome.NONE103}104105106107108109110111private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP112113114115116117118119internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =120 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance121122123124125126127128129130internal fun settingsDeckHeight(availableHeight: Dp): Dp {131 val constrained = availableHeight - DECK_BOTTOM_GUTTER132 return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)133}134135@Composable136fun SettingsScreen(137 onBack: () -> Unit,138 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},139 vm: SettingsViewModel = hiltViewModel(),140) {141 val entries by vm.entries.collectAsState()142 val settings by vm.settings.collectAsState()143 val isPro by vm.isPro.collectAsState()144 val showCelebration by vm.showCelebration.collectAsState()145 val reduceMotion = LocalReduceMotion.current146 val accent = LocalPeachAccent.current147 val scope = rememberCoroutineScope()148149 var active by remember { mutableStateOf(CardId.Pro) }150 var deckOpen by remember { mutableStateOf(true) }151 var fanning by remember { mutableStateOf(false) }152 var celebrating by remember { mutableStateOf<CardId?>(null) }153154 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)155156 fun handleBack() {157 if (!deckOpen) deckOpen = true else onBack()158 }159 BackHandler { handleBack() }160161 162 fun celebrate() {163 if (fanning) return164 scope.launch {165 fanning = true166 active = CardId.Pro167 CELEBRATION_FAN.forEachIndexed { i, id ->168 delay(150L)169 active = id170 }171 delay(150L)172 active = CardId.Pro173 fanning = false174 celebrating = CardId.Pro175 delay(900L)176 celebrating = null177 }178 }179180 181 182 183 184 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {185 ShellBackground {186 Column(187 Modifier188 .fillMaxSize()189 .statusBarsPadding()190 .padding(horizontal = PeachSpacing.md),191 ) {192 SettingsTopBar(193 activeIndex = activeIndex,194 total = STACK_CARDS.size,195 onBack = { handleBack() },196 )197 Spacer(Modifier.height(PeachSpacing.sm))198199 BoxWithConstraints(Modifier.fillMaxSize()) {200 val deckHeight = settingsDeckHeight(maxHeight)201 val focusHeight = deckHeight - 8.dp202 203 204 205 206 207 208 val expandedH = (209 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H210 ).coerceAtLeast(EXPANDED_H)211 Box(Modifier.fillMaxWidth().height(deckHeight)) {212 STACK_CARDS.forEachIndexed { index, card ->213 val isActive = card.id == active214 val distance = when {215 index == activeIndex -> -1216 index > activeIndex -> index - activeIndex - 1217 else -> STACK_CARDS.size - activeIndex + index - 1218 }219 val targetTop = when {220 isActive -> 0.dp221 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp222 else -> collapsedTabTop(distance, expandedH)223 }224 val targetHeight = when {225 isActive && deckOpen -> expandedH226 isActive -> focusHeight227 else -> TAB_CARD_H228 }229 val concealed = !deckOpen && !isActive230231 232 233 234 235 236 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)237 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)238 val top by animateDpAsState(239 targetTop,240 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,241 label = "top",242 )243 val cardH by animateDpAsState(244 targetHeight,245 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,246 label = "h",247 )248 val alpha by animateFloatAsState(249 if (concealed) 0f else 1f,250 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,251 label = "a",252 )253 val zIndex = if (isActive) 99f else (50 - distance).toFloat()254255 256 val shakeDy = remember { Animatable(0f) }257 val shakeRot = remember { Animatable(0f) }258 LaunchedEffect(celebrating == card.id) {259 if (celebrating == card.id && !reduceMotion) {260 launch {261 shakeRot.animateTo(0f, keyframes {262 durationMillis = 880263 0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757264 })265 }266 shakeDy.animateTo(0f, keyframes {267 durationMillis = 880268 0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757269 })270 }271 }272273 val density = LocalDensity.current274 StackCard(275 spec = card,276 active = isActive,277 focused = isActive && !deckOpen,278 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),279 showSparkle = card.id == CardId.Pro && isPro,280 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,281 body = {282 CardBody(283 id = card.id,284 fg = card.fg,285 vm = vm,286 settings = settings,287 entriesCount = entries.count { it.deletedAt == null },288 onNavigate = onNavigate,289 )290 },291 modifier = Modifier292 .fillMaxWidth()293 .align(Alignment.TopStart)294 .zIndex(zIndex)295 296 297 .pointerInput(card.id, deckOpen, isActive) {298 var dragY = 0f299 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }300 detectDragGestures(301 onDragStart = { dragY = 0f },302 onDrag = { change, amount -> change.consume(); dragY += amount.y },303 onDragEnd = {304 when (resolveStackDrag(dragY, thresholdPx, isActive && !deckOpen)) {305 DragOutcome.EXPAND -> { active = card.id; deckOpen = false }306 DragOutcome.COLLAPSE -> { deckOpen = true }307 DragOutcome.NONE -> {}308 }309 },310 )311 }312 .graphicsLayer {313 translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }314 rotationZ = shakeRot.value315 this.alpha = alpha316 }317 .height(cardH),318 onClick = {319 320 321 if (!isActive) {322 active = card.id323 deckOpen = false324 }325 },326 enabled = !concealed,327 )328 }329 }330 }331 }332 if (showCelebration) {333 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })334 }335 }336 }337}338339@Composable340private fun StackCard(341 spec: StackCardSpec,342 active: Boolean,343 focused: Boolean,344 lede: String,345 showSparkle: Boolean,346 onIconClick: (() -> Unit)?,347 body: @Composable () -> Unit,348 modifier: Modifier,349 onClick: () -> Unit,350 enabled: Boolean,351) {352 val interaction = remember { MutableInteractionSource() }353 Box(354 modifier355 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)356 .clip(RoundedCornerShape(PeachStackColors.CardRadius))357 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }358 .clickable(359 interactionSource = interaction,360 indication = null,361 enabled = enabled && !active || (active && !focused),362 ) { onClick() }363 .padding(364 horizontal = if (active) 18.dp else 16.dp,365 vertical = if (active) 16.dp else 12.dp,366 ),367 ) {368 if (showSparkle) SparkleDust(Modifier.fillMaxSize())369 if (active) {370 Column(Modifier.fillMaxSize()) {371 Row(372 Modifier.fillMaxWidth(),373 horizontalArrangement = Arrangement.SpaceBetween,374 verticalAlignment = Alignment.CenterVertically,375 ) {376 Text(377 spec.num,378 color = spec.fg.copy(alpha = 0.7f),379 fontFamily = PeachType.Mono,380 fontSize = 8.5.sp,381 letterSpacing = 0.26.em,382 )383 Box(384 Modifier.clip(RoundedCornerShape(999.dp))385 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)386 .padding(2.dp),387 ) {388 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))389 }390 }391 Text(392 spec.title,393 modifier = Modifier.padding(top = 6.dp),394 color = spec.fg,395 fontFamily = PeachType.Display,396 fontStyle = FontStyle.Italic,397 fontSize = 26.sp,398 )399 Text(400 lede,401 modifier = Modifier.padding(top = 4.dp),402 color = spec.fg.copy(alpha = 0.72f),403 fontFamily = PeachType.Display,404 fontStyle = FontStyle.Italic,405 fontSize = 12.5.sp,406 )407 408 409 Column(410 Modifier.fillMaxWidth().weight(1f)411 .padding(top = 14.dp)412 .verticalScroll(rememberScrollState()),413 ) {414 body()415 }416 }417 } else {418 419 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {420 Text(421 spec.title,422 color = spec.fg,423 fontFamily = PeachType.Display,424 fontStyle = FontStyle.Italic,425 fontWeight = FontWeight.Normal,426 fontSize = 19.sp,427 maxLines = 1,428 overflow = TextOverflow.Ellipsis,429 )430 }431 }432 }433}434435@Composable436private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {437 PeachTopBar(438 title = "Settings",439 leading = {440 Box(441 Modifier442 .size(40.dp)443 .clip(RoundedCornerShape(999.dp))444 .clickable(onClick = onBack),445 contentAlignment = Alignment.Center,446 ) {447 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)448 }449 },450 actions = {451 Text(452 "${activeIndex + 1} / $total",453 color = peachColors.T2,454 fontFamily = PeachType.Mono,455 fontSize = 9.sp,456 letterSpacing = 0.22.em,457 modifier = Modifier.padding(horizontal = PeachSpacing.sm),458 )459 },460 )461}462463464465466467468469private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {470 val a = Math.toRadians(155.0)471 val dx = kotlin.math.sin(a).toFloat()472 val dy = -kotlin.math.cos(a).toFloat()473 val cx = size.width / 2f474 val cy = size.height / 2f475 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f476 return Brush.linearGradient(477 colors = listOf(start, end),478 start = Offset(cx - dx * half, cy - dy * half),479 end = Offset(cx + dx * half, cy + dy * half),480 )481}482483484485486487488@Composable489private fun SparkleDust(modifier: Modifier = Modifier) {490 val reduceMotion = LocalReduceMotion.current491 val t = rememberInfiniteTransition(label = "dust")492 val drift by if (reduceMotion) {493 remember { mutableStateOf(0f) }494 } else {495 t.animateFloat(496 initialValue = 0f,497 targetValue = 1f,498 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),499 label = "drift",500 )501 }502 Box(503 modifier.drawWithCache {504 val tile = 48.dp.toPx()505 val dx = drift * tile506 onDrawBehind {507 val cols = (size.width / tile).toInt() + 2508 val rows = (size.height / tile).toInt() + 2509 for (r in 0..rows) {510 for (c in 0..cols) {511 val baseX = c * tile + dx512 val baseY = r * tile + dx513 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)514 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)515 }516 }517 }518 },519 )520}