SettingsScreen.kt

21.6 KB · raw

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.launch7374// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card75// sits at top:0; every other card is a thin tab fanned below it, ordered76// cyclically starting just after the active card so the stack reads as a loop.77private 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.dp8384/** Drag distance past which a stack card flips between focused and deck states. */85private val DRAG_THRESHOLD = 60.dp8687/** Outcome of releasing a vertical drag on a stack card. */88internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }8990/**91 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed92 * (deck) card up past the threshold focuses it; dragging the focused card down93 * past the threshold returns to the deck. Anything short of the threshold is a no-op.94 */95internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {96    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND97    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE98    else -> DragOutcome.NONE99}100101/**102 * Fixed vertical stride between consecutive collapsed tabs — browser parity103 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).104 * The deck reads as a tight physical stack fanned just below the active card, not105 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].106 */107private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP108109/**110 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]111 * from the active card. Mirrors the browser's deck-open layout so positions match112 * one-to-one. The active card sits at 0; the first fanned tab sits just under the113 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].114 */115internal fun collapsedTabTop(distance: Int): Dp =116    EXPANDED_H - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance117118/**119 * Browser StackDeck parity: the deck is a bounded region, not the whole settings120 * screen. The fanned tabs live inside this box so the dark shell does not show121 * as accidental gaps below the stack.122 */123internal 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    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.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    // The Settings stack always renders dark, regardless of the app theme174    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the175    // ShellBackground base (which keys off MaterialTheme) — while keeping the176    // user's chosen accent and reduce-motion preference.177    PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {178    ShellBackground {179        Column(Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 12.dp)) {180            // Masthead: back chevron · "Settings" · card counter.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                        // Celebration shake — keyframed bob + tilt as the Pro card lands.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                                // Drag the deck like a physical stack: flick a tab up to focus294                                // it, drag the focused card down to drop back into the deck.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                                // Tapping the active card is a no-op (browser StackDeck318                                // parity); flick it up to focus, a tab up to focus that one.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                // Body scrolls independently; only revealed at full height in406                // focus mode, but kept mounted so deck-mode shows a preview.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            // Tab: just the title, single line, vertically centred.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}433434/**435 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed436 * from the live draw size so the angle holds on any card height, unlike Compose's437 * default corner-to-corner linear gradient.438 */439private 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}452453/**454 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly455 * translate diagonally and loop, screen-blended at low opacity — the Compose456 * analogue of the browser's `stk-pro-dust` keyframes.457 */458@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}