SettingsScreen.kt

23.1 KB · raw

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.launch7778// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card79// sits at top:0; every other card is a thin tab fanned below it, ordered80// cyclically starting just after the active card so the stack reads as a loop.81private 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.dp8788/** Drag distance past which a stack card flips between focused and deck states. */89private val DRAG_THRESHOLD = 60.dp9091/** Outcome of releasing a vertical drag on a stack card. */92internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }9394/**95 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed96 * (deck) card up past the threshold focuses it; dragging the focused card down97 * past the threshold returns to the deck. Anything short of the threshold is a no-op.98 */99internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {100    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND101    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE102    else -> DragOutcome.NONE103}104105/**106 * Fixed vertical stride between consecutive collapsed tabs — browser parity107 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).108 * The deck reads as a tight physical stack fanned just below the active card, not109 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].110 */111private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP112113/**114 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]115 * from the active card. Mirrors the browser's deck-open layout so positions match116 * one-to-one. The active card sits at 0; the first fanned tab sits just under the117 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].118 */119internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =120    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance121122/**123 * The deck fills the available screen height (minus a bottom gutter) rather124 * than capping at [MAX_DECK_H] — that constant was ported verbatim from the125 * browser's small popup (`StackDeck.tsx`) and, left as a hard cap, stranded126 * a third of the screen as dead space below the stack on a phone-height127 * viewport. [MAX_DECK_H] still bounds the deck on very tall/tablet screens128 * so a focused card doesn't grow absurdly large.129 */130internal 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    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.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    // The Settings stack always renders dark, regardless of the app theme181    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the182    // ShellBackground base (which keys off MaterialTheme) — while keeping the183    // user's chosen accent and reduce-motion preference.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                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal203                // browser-popup constant) for the active card's preview height,204                // leaving the same dead space below the fanned tabs on a phone205                // screen that settingsDeckHeight itself was just fixed for. Size it206                // so the active preview + all 6 fanned tabs together fill deckHeight,207                // never shrinking below the original browser-derived minimum.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                        // The drag-to-focus/collapse gesture is a continuous, interruptible232                        // user interaction (not a one-shot navigation transition), so it gets233                        // real spring physics here rather than the fixed-duration tween used234                        // for the one-shot celebration fan — matching PeachExpandableCard and235                        // current M3 Expressive's physics-based spatial-spring motion model.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                        // Celebration shake — keyframed bob + tilt as the Pro card lands.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                                // Drag the deck like a physical stack: flick a tab up to focus296                                // it, drag the focused card down to drop back into the deck.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                                // Tapping the active card is a no-op (browser StackDeck320                                // parity); flick it up to focus, a tab up to focus that one.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                // Body scrolls independently; only revealed at full height in408                // focus mode, but kept mounted so deck-mode shows a preview.409                Column(410                    Modifier.fillMaxWidth().weight(1f)411                        .padding(top = 14.dp)412                        .verticalScroll(rememberScrollState()),413                ) {414                    body()415                }416            }417        } else {418            // Tab: just the title, single line, vertically centred.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}462463464/**465 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed466 * from the live draw size so the angle holds on any card height, unlike Compose's467 * default corner-to-corner linear gradient.468 */469private 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}482483/**484 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly485 * translate diagonally and loop, screen-blended at low opacity — the Compose486 * analogue of the browser's `stk-pro-dust` keyframes.487 */488@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}