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.runtime.Composable37import androidx.compose.runtime.LaunchedEffect38import androidx.compose.runtime.getValue39import androidx.compose.runtime.mutableStateOf40import androidx.compose.runtime.remember41import androidx.compose.runtime.rememberCoroutineScope42import androidx.compose.runtime.setValue43import androidx.compose.runtime.collectAsState44import androidx.compose.ui.Alignment45import androidx.compose.ui.Modifier46import androidx.compose.ui.draw.clip47import androidx.compose.ui.draw.drawWithCache48import androidx.compose.ui.draw.shadow49import androidx.compose.ui.draw.drawBehind50import androidx.compose.ui.geometry.Offset51import androidx.compose.ui.graphics.BlendMode52import androidx.compose.ui.graphics.Brush53import androidx.compose.ui.graphics.Color54import androidx.compose.ui.graphics.drawscope.DrawScope55import androidx.compose.ui.graphics.graphicsLayer56import androidx.compose.ui.input.pointer.pointerInput57import androidx.compose.ui.platform.LocalDensity58import androidx.compose.ui.unit.Dp59import androidx.compose.ui.text.font.FontStyle60import androidx.compose.ui.text.font.FontWeight61import androidx.compose.ui.text.style.TextOverflow62import androidx.compose.ui.unit.dp63import androidx.compose.ui.unit.em64import androidx.compose.ui.unit.sp65import androidx.compose.ui.zIndex66import androidx.hilt.navigation.compose.hiltViewModel67import androidx.compose.material3.Text68import com.peachpasswords.ui.components.PeachTopBar69import com.peachpasswords.ui.theme.LocalPeachAccent70import com.peachpasswords.ui.theme.LocalReduceMotion71import com.peachpasswords.ui.theme.PeachSpacing72import com.peachpasswords.ui.theme.peachColors73import com.peachpasswords.ui.theme.PeachMotion74import com.peachpasswords.ui.theme.PeachTheme75import com.peachpasswords.ui.theme.PeachStackColors76import com.peachpasswords.ui.theme.PeachType77import com.peachpasswords.ui.theme.ShellBackground78import kotlinx.coroutines.delay79import kotlinx.coroutines.launch8081828384private val EXPANDED_H = 258.dp85private val TAB_H = 46.dp86private val TAB_OVERLAP = 14.dp87private val TAB_CARD_H = TAB_H + 4.dp88private val MAX_DECK_H = 462.dp89private val DECK_BOTTOM_GUTTER = 36.dp909192private val DRAG_THRESHOLD = 60.dp939495internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }96979899100101102internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {103 dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND104 dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE105 else -> DragOutcome.NONE106}107108109110111112113114private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP115116117118119120121122internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =123 expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance124125126127128129130131132133internal fun settingsDeckHeight(availableHeight: Dp): Dp {134 val constrained = availableHeight - DECK_BOTTOM_GUTTER135 return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)136}137138@Composable139fun SettingsScreen(140 onBack: () -> Unit,141 onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},142 vm: SettingsViewModel = hiltViewModel(),143) {144 val entries by vm.entries.collectAsState()145 val settings by vm.settings.collectAsState()146 val isPro by vm.isPro.collectAsState()147 val showCelebration by vm.showCelebration.collectAsState()148 val reduceMotion = LocalReduceMotion.current149 val accent = LocalPeachAccent.current150 val scope = rememberCoroutineScope()151152 var active by remember { mutableStateOf(CardId.Pro) }153 var deckOpen by remember { mutableStateOf(true) }154 var fanning by remember { mutableStateOf(false) }155 var celebrating by remember { mutableStateOf<CardId?>(null) }156157 val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)158159 fun handleBack() {160 if (!deckOpen) deckOpen = true else onBack()161 }162 BackHandler { handleBack() }163164 165 fun celebrate() {166 if (fanning) return167 scope.launch {168 fanning = true169 active = CardId.Pro170 CELEBRATION_FAN.forEachIndexed { i, id ->171 delay(150L)172 active = id173 }174 delay(150L)175 active = CardId.Pro176 fanning = false177 celebrating = CardId.Pro178 delay(900L)179 celebrating = null180 }181 }182183 184 185 186 187 PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {188 ShellBackground {189 Column(190 Modifier191 .fillMaxSize()192 .statusBarsPadding()193 .padding(horizontal = PeachSpacing.md),194 ) {195 SettingsTopBar(196 activeIndex = activeIndex,197 total = STACK_CARDS.size,198 onBack = { handleBack() },199 )200 Spacer(Modifier.height(PeachSpacing.sm))201202 BoxWithConstraints(Modifier.fillMaxSize()) {203 val deckHeight = settingsDeckHeight(maxHeight)204 val focusHeight = deckHeight - 8.dp205 206 207 208 209 210 211 val expandedH = (212 deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H213 ).coerceAtLeast(EXPANDED_H)214 Box(Modifier.fillMaxWidth().height(deckHeight)) {215 STACK_CARDS.forEachIndexed { index, card ->216 val isActive = card.id == active217 val distance = when {218 index == activeIndex -> -1219 index > activeIndex -> index - activeIndex - 1220 else -> STACK_CARDS.size - activeIndex + index - 1221 }222 val targetTop = when {223 isActive -> 0.dp224 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp225 else -> collapsedTabTop(distance, expandedH)226 }227 val targetHeight = when {228 isActive && deckOpen -> expandedH229 isActive -> focusHeight230 else -> TAB_CARD_H231 }232 val concealed = !deckOpen && !isActive233234 235 236 237 238 239 val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)240 val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)241 val top by animateDpAsState(242 targetTop,243 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,244 label = "top",245 )246 val cardH by animateDpAsState(247 targetHeight,248 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,249 label = "h",250 )251 val alpha by animateFloatAsState(252 if (concealed) 0f else 1f,253 animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,254 label = "a",255 )256 val zIndex = if (isActive) 99f else (50 - distance).toFloat()257258 259 val shakeDy = remember { Animatable(0f) }260 val shakeRot = remember { Animatable(0f) }261 LaunchedEffect(celebrating == card.id) {262 if (celebrating == card.id && !reduceMotion) {263 launch {264 shakeRot.animateTo(0f, keyframes {265 durationMillis = 880266 0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757267 })268 }269 shakeDy.animateTo(0f, keyframes {270 durationMillis = 880271 0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757272 })273 }274 }275276 val density = LocalDensity.current277 StackCard(278 spec = card,279 active = isActive,280 focused = isActive && !deckOpen,281 lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),282 showSparkle = card.id == CardId.Pro && isPro,283 onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,284 body = {285 CardBody(286 id = card.id,287 fg = card.fg,288 vm = vm,289 settings = settings,290 entriesCount = entries.count { it.deletedAt == null },291 onNavigate = onNavigate,292 )293 },294 modifier = Modifier295 .fillMaxWidth()296 .align(Alignment.TopStart)297 .zIndex(zIndex)298 299 300 .pointerInput(card.id, deckOpen, isActive) {301 var dragY = 0f302 val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }303 detectDragGestures(304 onDragStart = { dragY = 0f },305 onDrag = { change, amount -> change.consume(); dragY += amount.y },306 onDragEnd = {307 when (resolveStackDrag(dragY, thresholdPx, isActive && !deckOpen)) {308 DragOutcome.EXPAND -> { active = card.id; deckOpen = false }309 DragOutcome.COLLAPSE -> { deckOpen = true }310 DragOutcome.NONE -> {}311 }312 },313 )314 }315 .graphicsLayer {316 translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }317 rotationZ = shakeRot.value318 this.alpha = alpha319 }320 .height(cardH),321 onClick = {322 323 324 if (!isActive) {325 active = card.id326 deckOpen = false327 }328 },329 onCollapseRequest = { deckOpen = true },330 enabled = !concealed,331 )332 }333 }334 }335 }336 if (showCelebration) {337 ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })338 }339 }340 }341}342343@Composable344private fun StackCard(345 spec: StackCardSpec,346 active: Boolean,347 focused: Boolean,348 lede: String,349 showSparkle: Boolean,350 onIconClick: (() -> Unit)?,351 body: @Composable () -> Unit,352 modifier: Modifier,353 onClick: () -> Unit,354 onCollapseRequest: () -> Unit,355 enabled: Boolean,356) {357 val interaction = remember { MutableInteractionSource() }358 Box(359 modifier360 .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)361 .clip(RoundedCornerShape(PeachStackColors.CardRadius))362 .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }363 .clickable(364 interactionSource = interaction,365 indication = null,366 enabled = enabled && !active || (active && !focused),367 ) { onClick() }368 .padding(369 horizontal = if (active) 18.dp else 16.dp,370 vertical = if (active) 16.dp else 12.dp,371 ),372 ) {373 if (showSparkle) SparkleDust(Modifier.fillMaxSize())374 if (active) {375 Column(Modifier.fillMaxSize()) {376 Row(377 Modifier.fillMaxWidth(),378 horizontalArrangement = Arrangement.SpaceBetween,379 verticalAlignment = Alignment.CenterVertically,380 ) {381 Text(382 spec.num,383 color = spec.fg.copy(alpha = 0.7f),384 fontFamily = PeachType.Mono,385 fontSize = 8.5.sp,386 letterSpacing = 0.26.em,387 )388 Box(389 Modifier.clip(RoundedCornerShape(999.dp))390 .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)391 .padding(2.dp),392 ) {393 CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))394 }395 }396 Text(397 spec.title,398 modifier = Modifier.padding(top = 6.dp),399 color = spec.fg,400 fontFamily = PeachType.Display,401 fontStyle = FontStyle.Italic,402 fontSize = 26.sp,403 )404 Text(405 lede,406 modifier = Modifier.padding(top = 4.dp),407 color = spec.fg.copy(alpha = 0.72f),408 fontFamily = PeachType.Display,409 fontStyle = FontStyle.Italic,410 fontSize = 12.5.sp,411 )412 413 414 415 416 417 418 val scrollState = rememberScrollState()419 val density = LocalDensity.current420 val collapseThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }421 val nestedScrollConnection = remember(scrollState, focused) {422 object : NestedScrollConnection {423 var collapseAccum = 0f424 override fun onPostScroll(425 consumed: Offset,426 available: Offset,427 source: NestedScrollSource,428 ): Offset {429 if (!focused || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {430 collapseAccum = 0f431 return Offset.Zero432 }433 collapseAccum += available.y434 if (collapseAccum >= collapseThresholdPx) {435 collapseAccum = 0f436 onCollapseRequest()437 }438 return Offset(0f, available.y)439 }440 }441 }442 Column(443 Modifier.fillMaxWidth().weight(1f)444 .padding(top = 14.dp)445 .nestedScroll(nestedScrollConnection)446 .verticalScroll(scrollState),447 ) {448 body()449 450 451 452 Spacer(Modifier.height(PeachSpacing.xl))453 }454 }455 } else {456 457 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {458 Text(459 spec.title,460 color = spec.fg,461 fontFamily = PeachType.Display,462 fontStyle = FontStyle.Italic,463 fontWeight = FontWeight.Normal,464 fontSize = 19.sp,465 maxLines = 1,466 overflow = TextOverflow.Ellipsis,467 )468 }469 }470 }471}472473@Composable474private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {475 PeachTopBar(476 title = "Settings",477 leading = {478 Box(479 Modifier480 .size(40.dp)481 .clip(RoundedCornerShape(999.dp))482 .clickable(onClick = onBack),483 contentAlignment = Alignment.Center,484 ) {485 Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)486 }487 },488 actions = {489 Text(490 "${activeIndex + 1} / $total",491 color = peachColors.T2,492 fontFamily = PeachType.Mono,493 fontSize = 9.sp,494 letterSpacing = 0.22.em,495 modifier = Modifier.padding(horizontal = PeachSpacing.sm),496 )497 },498 )499}500501502503504505506507private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {508 val a = Math.toRadians(155.0)509 val dx = kotlin.math.sin(a).toFloat()510 val dy = -kotlin.math.cos(a).toFloat()511 val cx = size.width / 2f512 val cy = size.height / 2f513 val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f514 return Brush.linearGradient(515 colors = listOf(start, end),516 start = Offset(cx - dx * half, cy - dy * half),517 end = Offset(cx + dx * half, cy + dy * half),518 )519}520521522523524525526@Composable527private fun SparkleDust(modifier: Modifier = Modifier) {528 val reduceMotion = LocalReduceMotion.current529 val t = rememberInfiniteTransition(label = "dust")530 val drift by if (reduceMotion) {531 remember { mutableStateOf(0f) }532 } else {533 t.animateFloat(534 initialValue = 0f,535 targetValue = 1f,536 animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),537 label = "drift",538 )539 }540 Box(541 modifier.drawWithCache {542 val tile = 48.dp.toPx()543 val dx = drift * tile544 onDrawBehind {545 val cols = (size.width / tile).toInt() + 2546 val rows = (size.height / tile).toInt() + 2547 for (r in 0..rows) {548 for (c in 0..cols) {549 val baseX = c * tile + dx550 val baseY = r * tile + dx551 drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)552 drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)553 }554 }555 }556 },557 )558}