1package com.peachpasswords.ui.settings23import androidx.activity.compose.BackHandler4import androidx.compose.animation.core.Animatable5import androidx.compose.animation.core.animateDpAsState6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.keyframes10import androidx.compose.animation.core.rememberInfiniteTransition11import androidx.compose.animation.core.tween12import androidx.compose.foundation.background13import androidx.compose.foundation.clickable14import androidx.compose.foundation.gestures.detectDragGestures15import androidx.compose.foundation.interaction.MutableInteractionSource16import androidx.compose.foundation.layout.Arrangement17import androidx.compose.foundation.layout.Box18import androidx.compose.foundation.layout.BoxWithConstraints19import androidx.compose.foundation.layout.Column20import androidx.compose.foundation.layout.Row21import androidx.compose.foundation.layout.Spacer22import androidx.compose.foundation.layout.fillMaxSize23import androidx.compose.foundation.layout.fillMaxWidth24import androidx.compose.foundation.layout.height25import androidx.compose.foundation.layout.padding26import androidx.compose.foundation.layout.size27import androidx.compose.foundation.layout.statusBarsPadding28import androidx.compose.foundation.shape.RoundedCornerShape29import androidx.compose.foundation.rememberScrollState30import androidx.compose.foundation.verticalScroll31import androidx.compose.runtime.Composable32import androidx.compose.runtime.LaunchedEffect33import androidx.compose.runtime.getValue34import androidx.compose.runtime.mutableStateOf35import androidx.compose.runtime.remember36import androidx.compose.runtime.rememberCoroutineScope37import androidx.compose.runtime.setValue38import androidx.compose.runtime.collectAsState39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.draw.clip42import androidx.compose.ui.draw.drawWithCache43import androidx.compose.ui.draw.shadow44import androidx.compose.ui.draw.drawBehind45import androidx.compose.ui.geometry.Offset46import androidx.compose.ui.graphics.BlendMode47import androidx.compose.ui.graphics.Brush48import androidx.compose.ui.graphics.Color49import androidx.compose.ui.graphics.drawscope.DrawScope50import androidx.compose.ui.graphics.graphicsLayer51import androidx.compose.ui.input.pointer.pointerInput52import androidx.compose.ui.platform.LocalDensity53import androidx.compose.ui.unit.Dp54import androidx.compose.ui.text.font.FontStyle55import androidx.compose.ui.text.font.FontWeight56import androidx.compose.ui.text.style.TextOverflow57import androidx.compose.ui.unit.dp58import androidx.compose.ui.unit.em59import androidx.compose.ui.unit.sp60import androidx.compose.ui.zIndex61import androidx.hilt.navigation.compose.hiltViewModel62import androidx.compose.material3.Text63import com.peachpasswords.ui.components.PeachTopBar64import com.peachpasswords.ui.theme.LocalPeachAccent65import com.peachpasswords.ui.theme.LocalReduceMotion66import com.peachpasswords.ui.theme.PeachSpacing67import com.peachpasswords.ui.theme.peachColors68import com.peachpasswords.ui.theme.PeachMotion69import com.peachpasswords.ui.theme.PeachTheme70import com.peachpasswords.ui.theme.PeachStackColors71import com.peachpasswords.ui.theme.PeachType72import com.peachpasswords.ui.theme.ShellBackground73import kotlinx.coroutines.delay74import kotlinx.coroutines.launch7576777879private val EXPANDED_H = 258.dp80private val TAB_H = 46.dp81private val TAB_OVERLAP = 14.dp82private val TAB_CARD_H = TAB_H + 4.dp83private val MAX_DECK_H = 462.dp84private val DECK_BOTTOM_GUTTER = 36.dp858687private val DRAG_THRESHOLD = 60.dp888990internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }91929394959697internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {98 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND99 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE100 else -> DragOutcome.NONE101}102103104105106107108109private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP110111112113114115116117internal fun collapsedTabTop(distance: Int): Dp =118 EXPANDED_H - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance119120121122123124125internal fun settingsDeckHeight(availableHeight: Dp): Dp {126 val constrained = availableHeight - DECK_BOTTOM_GUTTER127 return minOf(MAX_DECK_H, if (constrained < 0.dp) 0.dp else constrained)128}129130@Composable131fun SettingsScreen(132 onBack: () -> Unit,133 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},134 vm: SettingsViewModel = hiltViewModel(),135) {136 val entries by vm.entries.collectAsState()137 val settings by vm.settings.collectAsState()138 val isPro by vm.isPro.collectAsState()139 val showCelebration by vm.showCelebration.collectAsState()140 val reduceMotion = LocalReduceMotion.current141 val accent = LocalPeachAccent.current142 val scope = rememberCoroutineScope()143144 var active by remember { mutableStateOf(CardId.Pro) }145 var deckOpen by remember { mutableStateOf(true) }146 var fanning by remember { mutableStateOf(false) }147 var celebrating by remember { mutableStateOf<CardId?>(null) }148149 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)150151 fun handleBack() {152 if (!deckOpen) deckOpen = true else onBack()153 }154 BackHandler { handleBack() }155156 157 fun celebrate() {158 if (fanning) return159 scope.launch {160 fanning = true161 active = CardId.Pro162 CELEBRATION_FAN.forEachIndexed { i, id ->163 delay(150L)164 active = id165 }166 delay(150L)167 active = CardId.Pro168 fanning = false169 celebrating = CardId.Pro170 delay(900L)171 celebrating = null172 }173 }174175 176 177 178 179 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {180 ShellBackground {181 Column(182 Modifier183 .fillMaxSize()184 .statusBarsPadding()185 .padding(horizontal = PeachSpacing.md),186 ) {187 SettingsTopBar(188 activeIndex = activeIndex,189 total = STACK_CARDS.size,190 onBack = { handleBack() },191 )192 Spacer(Modifier.height(PeachSpacing.sm))193194 BoxWithConstraints(Modifier.fillMaxSize()) {195 val deckHeight = settingsDeckHeight(maxHeight)196 val focusHeight = deckHeight - 8.dp197 Box(Modifier.fillMaxWidth().height(deckHeight)) {198 STACK_CARDS.forEachIndexed { index, card ->199 val isActive = card.id == active200 val distance = when {201 index == activeIndex -> -1202 index > activeIndex -> index - activeIndex - 1203 else -> STACK_CARDS.size - activeIndex + index - 1204 }205 val targetTop = when {206 isActive -> 0.dp207 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp208 else -> collapsedTabTop(distance)209 }210 val targetHeight = when {211 isActive && deckOpen -> EXPANDED_H212 isActive -> focusHeight213 else -> TAB_CARD_H214 }215 val concealed = !deckOpen && !isActive216217 val animSpec = if (fanning) {218 tween<Float>(140, easing = PeachMotion.Celebrate)219 } else {220 tween<Float>(PeachMotion.Stack, easing = PeachMotion.StackSpring)221 }222 val top by animateDpAsState(223 targetTop,224 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate)225 else tween(PeachMotion.Stack, easing = PeachMotion.StackSpring),226 label = "top",227 )228 val cardH by animateDpAsState(229 targetHeight,230 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate)231 else tween(PeachMotion.Stack, easing = PeachMotion.StackSpring),232 label = "h",233 )234 val alpha by animateFloatAsState(if (concealed) 0f else 1f, animSpec, label = "a")235 val zIndex = if (isActive) 99f else (50 - distance).toFloat()236237 238 val shakeDy = remember { Animatable(0f) }239 val shakeRot = remember { Animatable(0f) }240 LaunchedEffect(celebrating == card.id) {241 if (celebrating == card.id && !reduceMotion) {242 launch {243 shakeRot.animateTo(0f, keyframes {244 durationMillis = 880245 0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757246 })247 }248 shakeDy.animateTo(0f, keyframes {249 durationMillis = 880250 0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757251 })252 }253 }254255 val density = LocalDensity.current256 StackCard(257 spec = card,258 active = isActive,259 focused = isActive && !deckOpen,260 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),261 showSparkle = card.id == CardId.Pro && isPro,262 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,263 body = {264 CardBody(265 id = card.id,266 fg = card.fg,267 vm = vm,268 settings = settings,269 entriesCount = entries.count { it.deletedAt == null },270 onNavigate = onNavigate,271 )272 },273 modifier = Modifier274 .fillMaxWidth()275 .align(Alignment.TopStart)276 .zIndex(zIndex)277 278 279 .pointerInput(card.id, deckOpen, isActive) {280 var dragY = 0f281 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }282 detectDragGestures(283 onDragStart = { dragY = 0f },284 onDrag = { change, amount -> change.consume(); dragY += amount.y },285 onDragEnd = {286 when (resolveStackDrag(dragY, thresholdPx, isActive && !deckOpen)) {287 DragOutcome.EXPAND -> { active = card.id; deckOpen = false }288 DragOutcome.COLLAPSE -> { deckOpen = true }289 DragOutcome.NONE -> {}290 }291 },292 )293 }294 .graphicsLayer {295 translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }296 rotationZ = shakeRot.value297 this.alpha = alpha298 }299 .height(cardH),300 onClick = {301 302 303 if (!isActive) {304 active = card.id305 deckOpen = false306 }307 },308 enabled = !concealed,309 )310 }311 }312 }313 }314 if (showCelebration) {315 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })316 }317 }318 }319}320321@Composable322private fun StackCard(323 spec: StackCardSpec,324 active: Boolean,325 focused: Boolean,326 lede: String,327 showSparkle: Boolean,328 onIconClick: (() -> Unit)?,329 body: @Composable () -> Unit,330 modifier: Modifier,331 onClick: () -> Unit,332 enabled: Boolean,333) {334 val interaction = remember { MutableInteractionSource() }335 Box(336 modifier337 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)338 .clip(RoundedCornerShape(PeachStackColors.CardRadius))339 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }340 .clickable(341 interactionSource = interaction,342 indication = null,343 enabled = enabled && !active || (active && !focused),344 ) { onClick() }345 .padding(346 horizontal = if (active) 18.dp else 16.dp,347 vertical = if (active) 16.dp else 12.dp,348 ),349 ) {350 if (showSparkle) SparkleDust(Modifier.fillMaxSize())351 if (active) {352 Column(Modifier.fillMaxSize()) {353 Row(354 Modifier.fillMaxWidth(),355 horizontalArrangement = Arrangement.SpaceBetween,356 verticalAlignment = Alignment.CenterVertically,357 ) {358 Text(359 spec.num,360 color = spec.fg.copy(alpha = 0.7f),361 fontFamily = PeachType.Mono,362 fontSize = 8.5.sp,363 letterSpacing = 0.26.em,364 )365 Box(366 Modifier.clip(RoundedCornerShape(999.dp))367 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)368 .padding(2.dp),369 ) {370 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))371 }372 }373 Text(374 spec.title,375 modifier = Modifier.padding(top = 6.dp),376 color = spec.fg,377 fontFamily = PeachType.Display,378 fontStyle = FontStyle.Italic,379 fontSize = 26.sp,380 )381 Text(382 lede,383 modifier = Modifier.padding(top = 4.dp),384 color = spec.fg.copy(alpha = 0.72f),385 fontFamily = PeachType.Display,386 fontStyle = FontStyle.Italic,387 fontSize = 12.5.sp,388 )389 390 391 Column(392 Modifier.fillMaxWidth().weight(1f)393 .padding(top = 14.dp)394 .verticalScroll(rememberScrollState()),395 ) {396 body()397 SettingsCardEndState(spec.fg)398 }399 }400 } else {401 402 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {403 Text(404 spec.title,405 color = spec.fg,406 fontFamily = PeachType.Display,407 fontStyle = FontStyle.Italic,408 fontWeight = FontWeight.Normal,409 fontSize = 19.sp,410 maxLines = 1,411 overflow = TextOverflow.Ellipsis,412 )413 }414 }415 }416}417418@Composable419private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {420 PeachTopBar(421 title = "Settings",422 leading = {423 Box(424 Modifier425 .size(40.dp)426 .clip(RoundedCornerShape(999.dp))427 .clickable(onClick = onBack),428 contentAlignment = Alignment.Center,429 ) {430 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)431 }432 },433 actions = {434 Text(435 "${activeIndex + 1} / $total",436 color = peachColors.T2,437 fontFamily = PeachType.Mono,438 fontSize = 9.sp,439 letterSpacing = 0.22.em,440 modifier = Modifier.padding(horizontal = PeachSpacing.sm),441 )442 },443 )444}445446@Composable447private fun SettingsCardEndState(fg: Color) {448 Column(449 Modifier450 .fillMaxWidth()451 .padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),452 horizontalAlignment = Alignment.CenterHorizontally,453 verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs),454 ) {455 Text(456 "end of card",457 color = fg.copy(alpha = 0.62f),458 fontFamily = PeachType.Display,459 fontStyle = FontStyle.Italic,460 fontSize = 16.sp,461 )462 Text(463 "Drag or tap the stack to move between settings.",464 color = fg.copy(alpha = 0.45f),465 fontFamily = PeachType.Mono,466 fontSize = 8.5.sp,467 letterSpacing = 0.08.em,468 )469 }470}471472473474475476477private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {478 val a = Math.toRadians(155.0)479 val dx = kotlin.math.sin(a).toFloat()480 val dy = -kotlin.math.cos(a).toFloat()481 val cx = size.width / 2f482 val cy = size.height / 2f483 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f484 return Brush.linearGradient(485 colors = listOf(start, end),486 start = Offset(cx - dx * half, cy - dy * half),487 end = Offset(cx + dx * half, cy + dy * half),488 )489}490491492493494495496@Composable497private fun SparkleDust(modifier: Modifier = Modifier) {498 val reduceMotion = LocalReduceMotion.current499 val t = rememberInfiniteTransition(label = "dust")500 val drift by if (reduceMotion) {501 remember { mutableStateOf(0f) }502 } else {503 t.animateFloat(504 initialValue = 0f,505 targetValue = 1f,506 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),507 label = "drift",508 )509 }510 Box(511 modifier.drawWithCache {512 val tile = 48.dp.toPx()513 val dx = drift * tile514 onDrawBehind {515 val cols = (size.width / tile).toInt() + 2516 val rows = (size.height / tile).toInt() + 2517 for (r in 0..rows) {518 for (c in 0..cols) {519 val baseX = c * tile + dx520 val baseY = r * tile + dx521 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)522 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)523 }524 }525 }526 },527 )528}