SettingsScreen.kt

25.2 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.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.launch8081// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card82// sits at top:0; every other card is a thin tab fanned below it, ordered83// cyclically starting just after the active card so the stack reads as a loop.84private 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.dp9091/** Drag distance past which a stack card flips between focused and deck states. */92private val DRAG_THRESHOLD = 60.dp9394/** Outcome of releasing a vertical drag on a stack card. */95internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }9697/**98 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed99 * (deck) card up past the threshold focuses it; dragging the focused card down100 * past the threshold returns to the deck. Anything short of the threshold is a no-op.101 */102internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {103    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND104    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE105    else -> DragOutcome.NONE106}107108/**109 * Fixed vertical stride between consecutive collapsed tabs — browser parity110 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).111 * The deck reads as a tight physical stack fanned just below the active card, not112 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].113 */114private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP115116/**117 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]118 * from the active card. Mirrors the browser's deck-open layout so positions match119 * one-to-one. The active card sits at 0; the first fanned tab sits just under the120 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].121 */122internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =123    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance124125/**126 * The deck fills the available screen height (minus a bottom gutter) rather127 * than capping at [MAX_DECK_H] — that constant was ported verbatim from the128 * browser's small popup (`StackDeck.tsx`) and, left as a hard cap, stranded129 * a third of the screen as dead space below the stack on a phone-height130 * viewport. [MAX_DECK_H] still bounds the deck on very tall/tablet screens131 * so a focused card doesn't grow absurdly large.132 */133internal 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    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.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    // The Settings stack always renders dark, regardless of the app theme184    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the185    // ShellBackground base (which keys off MaterialTheme) — while keeping the186    // user's chosen accent and reduce-motion preference.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                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal206                // browser-popup constant) for the active card's preview height,207                // leaving the same dead space below the fanned tabs on a phone208                // screen that settingsDeckHeight itself was just fixed for. Size it209                // so the active preview + all 6 fanned tabs together fill deckHeight,210                // never shrinking below the original browser-derived minimum.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                        // The drag-to-focus/collapse gesture is a continuous, interruptible235                        // user interaction (not a one-shot navigation transition), so it gets236                        // real spring physics here rather than the fixed-duration tween used237                        // for the one-shot celebration fan — matching PeachExpandableCard and238                        // current M3 Expressive's physics-based spatial-spring motion model.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                        // Celebration shake — keyframed bob + tilt as the Pro card lands.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                                // Drag the deck like a physical stack: flick a tab up to focus299                                // it, drag the focused card down to drop back into the deck.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                                // Tapping the active card is a no-op (browser StackDeck323                                // parity); flick it up to focus, a tab up to focus that one.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                // Body scrolls independently; only revealed at full height in413                // focus mode, but kept mounted so deck-mode shows a preview.414                // Collapse-by-scroll: once the body is already at its scroll415                // top, a further downward drag has nowhere to go inside the416                // scrollable — hand that leftover delta to the deck instead417                // of letting it disappear as a no-op overscroll.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                    // Trailing breathing room so bottom-of-card content (e.g. the450                    // Pro card's CTA button) never sits flush against the fanned451                    // tab stacked directly beneath it.452                    Spacer(Modifier.height(PeachSpacing.xl))453                }454            }455        } else {456            // Tab: just the title, single line, vertically centred.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}500501502/**503 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed504 * from the live draw size so the angle holds on any card height, unlike Compose's505 * default corner-to-corner linear gradient.506 */507private 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}520521/**522 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly523 * translate diagonally and loop, screen-blended at low opacity — the Compose524 * analogue of the browser's `stk-pro-dust` keyframes.525 */526@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}