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.rememberScrollState29import androidx.compose.foundation.shape.RoundedCornerShape30import 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.theme.LocalPeachAccent64import com.peachpasswords.ui.theme.LocalReduceMotion65import com.peachpasswords.ui.theme.peachColors66import com.peachpasswords.ui.theme.PeachMotion67import com.peachpasswords.ui.theme.PeachTheme68import com.peachpasswords.ui.theme.PeachStackColors69import com.peachpasswords.ui.theme.PeachType70import com.peachpasswords.ui.theme.ShellBackground71import kotlinx.coroutines.delay72import kotlinx.coroutines.launch7374757677private val EXPANDED_H = 258.dp78private val TAB_H = 46.dp79private val TAB_OVERLAP = 14.dp80private val TAB_CARD_H = TAB_H + 4.dp81private val MAX_DECK_H = 462.dp82private val DECK_BOTTOM_GUTTER = 36.dp838485private val DRAG_THRESHOLD = 60.dp868788internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }89909192939495internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {96 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND97 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE98 else -> DragOutcome.NONE99}100101102103104105106107private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP108109110111112113114115internal fun collapsedTabTop(distance: Int): Dp =116 EXPANDED_H - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance117118119120121122123internal fun settingsDeckHeight(availableHeight: Dp): Dp {124 val constrained = availableHeight - DECK_BOTTOM_GUTTER125 return minOf(MAX_DECK_H, if (constrained < 0.dp) 0.dp else constrained)126}127128@Composable129fun SettingsScreen(130 onBack: () -> Unit,131 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},132 vm: SettingsViewModel = hiltViewModel(),133) {134 val entries by vm.entries.collectAsState()135 val settings by vm.settings.collectAsState()136 val isPro by vm.isPro.collectAsState()137 val showCelebration by vm.showCelebration.collectAsState()138 val reduceMotion = LocalReduceMotion.current139 val accent = LocalPeachAccent.current140 val scope = rememberCoroutineScope()141142 var active by remember { mutableStateOf(CardId.Pro) }143 var deckOpen by remember { mutableStateOf(true) }144 var fanning by remember { mutableStateOf(false) }145 var celebrating by remember { mutableStateOf<CardId?>(null) }146147 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)148149 fun handleBack() {150 if (!deckOpen) deckOpen = true else onBack()151 }152 BackHandler { handleBack() }153154 155 fun celebrate() {156 if (fanning) return157 scope.launch {158 fanning = true159 active = CardId.Pro160 CELEBRATION_FAN.forEachIndexed { i, id ->161 delay(150L)162 active = id163 }164 delay(150L)165 active = CardId.Pro166 fanning = false167 celebrating = CardId.Pro168 delay(900L)169 celebrating = null170 }171 }172173 174 175 176 177 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {178 ShellBackground {179 Column(Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 12.dp)) {180 181 Row(182 Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 8.dp),183 verticalAlignment = Alignment.CenterVertically,184 ) {185 Box(186 Modifier.size(28.dp).clip(RoundedCornerShape(999.dp))187 .background(Color(0x04F0E8D8))188 .clickable { handleBack() },189 contentAlignment = Alignment.Center,190 ) {191 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 16.sp)192 }193 Text(194 "Settings",195 modifier = Modifier.weight(1f).padding(start = 10.dp),196 color = peachColors.T0,197 fontFamily = PeachType.Display,198 fontStyle = FontStyle.Italic,199 fontSize = 22.sp,200 )201 Text(202 "${activeIndex + 1} / ${STACK_CARDS.size}",203 color = peachColors.T2,204 fontFamily = PeachType.Mono,205 fontSize = 9.sp,206 letterSpacing = 0.22.em,207 )208 }209210 BoxWithConstraints(Modifier.fillMaxSize()) {211 val deckHeight = settingsDeckHeight(maxHeight)212 val focusHeight = deckHeight - 8.dp213 Box(Modifier.fillMaxWidth().height(deckHeight)) {214 STACK_CARDS.forEachIndexed { index, card ->215 val isActive = card.id == active216 val distance = when {217 index == activeIndex -> -1218 index > activeIndex -> index - activeIndex - 1219 else -> STACK_CARDS.size - activeIndex + index - 1220 }221 val targetTop = when {222 isActive -> 0.dp223 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp224 else -> collapsedTabTop(distance)225 }226 val targetHeight = when {227 isActive && deckOpen -> EXPANDED_H228 isActive -> focusHeight229 else -> TAB_CARD_H230 }231 val concealed = !deckOpen && !isActive232233 val animSpec = if (fanning) {234 tween<Float>(140, easing = PeachMotion.Celebrate)235 } else {236 tween<Float>(PeachMotion.Stack, easing = PeachMotion.StackSpring)237 }238 val top by animateDpAsState(239 targetTop,240 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate)241 else tween(PeachMotion.Stack, easing = PeachMotion.StackSpring),242 label = "top",243 )244 val cardH by animateDpAsState(245 targetHeight,246 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate)247 else tween(PeachMotion.Stack, easing = PeachMotion.StackSpring),248 label = "h",249 )250 val alpha by animateFloatAsState(if (concealed) 0f else 1f, animSpec, label = "a")251 val zIndex = if (isActive) 99f else (50 - distance).toFloat()252253 254 val shakeDy = remember { Animatable(0f) }255 val shakeRot = remember { Animatable(0f) }256 LaunchedEffect(celebrating == card.id) {257 if (celebrating == card.id && !reduceMotion) {258 launch {259 shakeRot.animateTo(0f, keyframes {260 durationMillis = 880261 0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757262 })263 }264 shakeDy.animateTo(0f, keyframes {265 durationMillis = 880266 0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757267 })268 }269 }270271 val density = LocalDensity.current272 StackCard(273 spec = card,274 active = isActive,275 focused = isActive && !deckOpen,276 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),277 showSparkle = card.id == CardId.Pro && isPro,278 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,279 body = {280 CardBody(281 id = card.id,282 fg = card.fg,283 vm = vm,284 settings = settings,285 entriesCount = entries.count { it.deletedAt == null },286 onNavigate = onNavigate,287 )288 },289 modifier = Modifier290 .fillMaxWidth()291 .align(Alignment.TopStart)292 .zIndex(zIndex)293 294 295 .pointerInput(card.id, deckOpen, isActive) {296 var dragY = 0f297 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }298 detectDragGestures(299 onDragStart = { dragY = 0f },300 onDrag = { change, amount -> change.consume(); dragY += amount.y },301 onDragEnd = {302 when (resolveStackDrag(dragY, thresholdPx, isActive && !deckOpen)) {303 DragOutcome.EXPAND -> { active = card.id; deckOpen = false }304 DragOutcome.COLLAPSE -> { deckOpen = true }305 DragOutcome.NONE -> {}306 }307 },308 )309 }310 .graphicsLayer {311 translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }312 rotationZ = shakeRot.value313 this.alpha = alpha314 }315 .height(cardH),316 onClick = {317 318 319 if (!isActive) {320 active = card.id321 deckOpen = false322 }323 },324 enabled = !concealed,325 )326 }327 }328 }329 }330 if (showCelebration) {331 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })332 }333 }334 }335}336337@Composable338private fun StackCard(339 spec: StackCardSpec,340 active: Boolean,341 focused: Boolean,342 lede: String,343 showSparkle: Boolean,344 onIconClick: (() -> Unit)?,345 body: @Composable () -> Unit,346 modifier: Modifier,347 onClick: () -> Unit,348 enabled: Boolean,349) {350 val interaction = remember { MutableInteractionSource() }351 Box(352 modifier353 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)354 .clip(RoundedCornerShape(PeachStackColors.CardRadius))355 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }356 .clickable(357 interactionSource = interaction,358 indication = null,359 enabled = enabled && !active || (active && !focused),360 ) { onClick() }361 .padding(362 horizontal = if (active) 18.dp else 16.dp,363 vertical = if (active) 16.dp else 12.dp,364 ),365 ) {366 if (showSparkle) SparkleDust(Modifier.fillMaxSize())367 if (active) {368 Column(Modifier.fillMaxSize()) {369 Row(370 Modifier.fillMaxWidth(),371 horizontalArrangement = Arrangement.SpaceBetween,372 verticalAlignment = Alignment.CenterVertically,373 ) {374 Text(375 spec.num,376 color = spec.fg.copy(alpha = 0.7f),377 fontFamily = PeachType.Mono,378 fontSize = 8.5.sp,379 letterSpacing = 0.26.em,380 )381 Box(382 Modifier.clip(RoundedCornerShape(999.dp))383 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)384 .padding(2.dp),385 ) {386 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))387 }388 }389 Text(390 spec.title,391 modifier = Modifier.padding(top = 6.dp),392 color = spec.fg,393 fontFamily = PeachType.Display,394 fontStyle = FontStyle.Italic,395 fontSize = 26.sp,396 )397 Text(398 lede,399 modifier = Modifier.padding(top = 4.dp),400 color = spec.fg.copy(alpha = 0.72f),401 fontFamily = PeachType.Display,402 fontStyle = FontStyle.Italic,403 fontSize = 12.5.sp,404 )405 406 407 Column(408 Modifier.fillMaxWidth().weight(1f)409 .padding(top = 14.dp)410 .verticalScroll(rememberScrollState()),411 ) {412 body()413 Spacer(Modifier.height(24.dp))414 }415 }416 } else {417 418 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {419 Text(420 spec.title,421 color = spec.fg,422 fontFamily = PeachType.Display,423 fontStyle = FontStyle.Italic,424 fontWeight = FontWeight.Normal,425 fontSize = 19.sp,426 maxLines = 1,427 overflow = TextOverflow.Ellipsis,428 )429 }430 }431 }432}433434435436437438439private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {440 val a = Math.toRadians(155.0)441 val dx = kotlin.math.sin(a).toFloat()442 val dy = -kotlin.math.cos(a).toFloat()443 val cx = size.width / 2f444 val cy = size.height / 2f445 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f446 return Brush.linearGradient(447 colors = listOf(start, end),448 start = Offset(cx - dx * half, cy - dy * half),449 end = Offset(cx + dx * half, cy + dy * half),450 )451}452453454455456457458@Composable459private fun SparkleDust(modifier: Modifier = Modifier) {460 val reduceMotion = LocalReduceMotion.current461 val t = rememberInfiniteTransition(label = "dust")462 val drift by if (reduceMotion) {463 remember { mutableStateOf(0f) }464 } else {465 t.animateFloat(466 initialValue = 0f,467 targetValue = 1f,468 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),469 label = "drift",470 )471 }472 Box(473 modifier.drawWithCache {474 val tile = 48.dp.toPx()475 val dx = drift * tile476 onDrawBehind {477 val cols = (size.width / tile).toInt() + 2478 val rows = (size.height / tile).toInt() + 2479 for (r in 0..rows) {480 for (c in 0..cols) {481 val baseX = c * tile + dx482 val baseY = r * tile + dx483 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)484 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)485 }486 }487 }488 },489 )490}