SettingsScreen.kt

22.4 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.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.launch7576// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card77// sits at top:0; every other card is a thin tab fanned below it, ordered78// cyclically starting just after the active card so the stack reads as a loop.79private 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.dp8586/** Drag distance past which a stack card flips between focused and deck states. */87private val DRAG_THRESHOLD = 60.dp8889/** Outcome of releasing a vertical drag on a stack card. */90internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }9192/**93 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed94 * (deck) card up past the threshold focuses it; dragging the focused card down95 * past the threshold returns to the deck. Anything short of the threshold is a no-op.96 */97internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {98    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND99    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE100    else -> DragOutcome.NONE101}102103/**104 * Fixed vertical stride between consecutive collapsed tabs — browser parity105 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).106 * The deck reads as a tight physical stack fanned just below the active card, not107 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].108 */109private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP110111/**112 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]113 * from the active card. Mirrors the browser's deck-open layout so positions match114 * one-to-one. The active card sits at 0; the first fanned tab sits just under the115 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].116 */117internal fun collapsedTabTop(distance: Int): Dp =118    EXPANDED_H - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance119120/**121 * Browser StackDeck parity: the deck is a bounded region, not the whole settings122 * screen. The fanned tabs live inside this box so the dark shell does not show123 * as accidental gaps below the stack.124 */125internal 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    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.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    // The Settings stack always renders dark, regardless of the app theme176    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the177    // ShellBackground base (which keys off MaterialTheme) — while keeping the178    // user's chosen accent and reduce-motion preference.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                        // Celebration shake — keyframed bob + tilt as the Pro card lands.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                                // Drag the deck like a physical stack: flick a tab up to focus278                                // it, drag the focused card down to drop back into the deck.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                                // Tapping the active card is a no-op (browser StackDeck302                                // parity); flick it up to focus, a tab up to focus that one.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                // Body scrolls independently; only revealed at full height in390                // focus mode, but kept mounted so deck-mode shows a preview.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            // Tab: just the title, single line, vertically centred.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}471472/**473 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed474 * from the live draw size so the angle holds on any card height, unlike Compose's475 * default corner-to-corner linear gradient.476 */477private 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}490491/**492 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly493 * translate diagonally and loop, screen-blended at low opacity — the Compose494 * analogue of the browser's `stk-pro-dust` keyframes.495 */496@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}