SettingsScreen.kt

27.9 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.ui.unit.Velocity37import androidx.compose.runtime.Composable38import androidx.compose.runtime.LaunchedEffect39import androidx.compose.runtime.getValue40import androidx.compose.runtime.mutableStateOf41import androidx.compose.runtime.remember42import androidx.compose.runtime.rememberCoroutineScope43import androidx.compose.runtime.rememberUpdatedState44import androidx.compose.runtime.setValue45import androidx.compose.runtime.collectAsState46import androidx.compose.ui.Alignment47import androidx.compose.ui.Modifier48import androidx.compose.ui.draw.clip49import androidx.compose.ui.draw.drawWithCache50import androidx.compose.ui.draw.shadow51import androidx.compose.ui.draw.drawBehind52import androidx.compose.ui.geometry.Offset53import androidx.compose.ui.graphics.BlendMode54import androidx.compose.ui.graphics.Brush55import androidx.compose.ui.graphics.Color56import androidx.compose.ui.graphics.drawscope.DrawScope57import androidx.compose.ui.graphics.graphicsLayer58import androidx.compose.ui.input.pointer.pointerInput59import androidx.compose.ui.platform.LocalDensity60import androidx.compose.ui.unit.Dp61import androidx.compose.ui.text.font.FontStyle62import androidx.compose.ui.text.font.FontWeight63import androidx.compose.ui.text.style.TextOverflow64import androidx.compose.ui.unit.dp65import androidx.compose.ui.unit.em66import androidx.compose.ui.unit.sp67import androidx.compose.ui.zIndex68import androidx.hilt.navigation.compose.hiltViewModel69import androidx.compose.material3.Text70import com.peachpasswords.ui.components.PeachTopBar71import com.peachpasswords.ui.theme.LocalPeachAccent72import com.peachpasswords.ui.theme.LocalReduceMotion73import com.peachpasswords.ui.theme.PeachSpacing74import com.peachpasswords.ui.theme.peachColors75import com.peachpasswords.ui.theme.PeachMotion76import com.peachpasswords.ui.theme.PeachTheme77import com.peachpasswords.ui.theme.PeachStackColors78import com.peachpasswords.ui.theme.PeachType79import com.peachpasswords.ui.theme.ShellBackground80import kotlinx.coroutines.delay81import kotlinx.coroutines.launch8283// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card84// sits at top:0; every other card is a thin tab fanned below it, ordered85// cyclically starting just after the active card so the stack reads as a loop.86private val EXPANDED_H = 258.dp87private val TAB_H = 46.dp88private val TAB_OVERLAP = 14.dp89private val TAB_CARD_H = TAB_H + 4.dp90private val MAX_DECK_H = 462.dp91private val DECK_BOTTOM_GUTTER = 36.dp9293/** Drag distance past which a stack card flips between focused and deck states. */94private val DRAG_THRESHOLD = 60.dp9596/** Outcome of releasing a vertical drag on a stack card. */97internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }9899/**100 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed101 * (deck) card up past the threshold focuses it; dragging the focused card down102 * past the threshold returns to the deck. Anything short of the threshold is a no-op.103 */104internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {105    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND106    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE107    else -> DragOutcome.NONE108}109110/**111 * Fixed vertical stride between consecutive collapsed tabs — browser parity112 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).113 * The deck reads as a tight physical stack fanned just below the active card, not114 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].115 */116private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP117118/**119 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]120 * from the active card. Mirrors the browser's deck-open layout so positions match121 * one-to-one. The active card sits at 0; the first fanned tab sits just under the122 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].123 */124internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =125    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance126127/**128 * The deck fills the available screen height (minus a bottom gutter) rather129 * than capping at [MAX_DECK_H] — that constant was ported verbatim from the130 * browser's small popup (`StackDeck.tsx`) and, left as a hard cap, stranded131 * a third of the screen as dead space below the stack on a phone-height132 * viewport. [MAX_DECK_H] still bounds the deck on very tall/tablet screens133 * so a focused card doesn't grow absurdly large.134 */135internal fun settingsDeckHeight(availableHeight: Dp): Dp {136    val constrained = availableHeight - DECK_BOTTOM_GUTTER137    return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)138}139140@Composable141fun SettingsScreen(142    onBack: () -> Unit,143    onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},144    vm: SettingsViewModel = hiltViewModel(),145) {146    val entries by vm.entries.collectAsState()147    val settings by vm.settings.collectAsState()148    val isPro by vm.isPro.collectAsState()149    val showCelebration by vm.showCelebration.collectAsState()150    val reduceMotion = LocalReduceMotion.current151    val accent = LocalPeachAccent.current152    val scope = rememberCoroutineScope()153154    var active by remember { mutableStateOf(CardId.Pro) }155    var deckOpen by remember { mutableStateOf(true) }156    var fanning by remember { mutableStateOf(false) }157    var celebrating by remember { mutableStateOf<CardId?>(null) }158159    val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)160161    fun handleBack() {162        if (!deckOpen) deckOpen = true else onBack()163    }164    BackHandler { handleBack() }165166    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.167    fun celebrate() {168        if (fanning) return169        scope.launch {170            fanning = true171            active = CardId.Pro172            CELEBRATION_FAN.forEachIndexed { i, id ->173                delay(150L)174                active = id175            }176            delay(150L)177            active = CardId.Pro178            fanning = false179            celebrating = CardId.Pro180            delay(900L)181            celebrating = null182        }183    }184185    // The Settings stack always renders dark, regardless of the app theme186    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the187    // ShellBackground base (which keys off MaterialTheme) — while keeping the188    // user's chosen accent and reduce-motion preference.189    PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {190    ShellBackground {191        Column(192            Modifier193                .fillMaxSize()194                .statusBarsPadding()195                .padding(horizontal = PeachSpacing.md),196        ) {197            SettingsTopBar(198                activeIndex = activeIndex,199                total = STACK_CARDS.size,200                onBack = { handleBack() },201            )202            Spacer(Modifier.height(PeachSpacing.sm))203204            BoxWithConstraints(Modifier.fillMaxSize()) {205                val deckHeight = settingsDeckHeight(maxHeight)206                val focusHeight = deckHeight - 8.dp207                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal208                // browser-popup constant) for the active card's preview height,209                // leaving the same dead space below the fanned tabs on a phone210                // screen that settingsDeckHeight itself was just fixed for. Size it211                // so the active preview + all 6 fanned tabs together fill deckHeight,212                // never shrinking below the original browser-derived minimum.213                val expandedH = (214                    deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H215                ).coerceAtLeast(EXPANDED_H)216                Box(Modifier.fillMaxWidth().height(deckHeight)) {217                    STACK_CARDS.forEachIndexed { index, card ->218                        val isActive = card.id == active219                        val distance = when {220                            index == activeIndex -> -1221                            index > activeIndex -> index - activeIndex - 1222                            else -> STACK_CARDS.size - activeIndex + index - 1223                        }224                        val targetTop = when {225                            isActive -> 0.dp226                            !deckOpen -> focusHeight + 44.dp + (distance * 8).dp227                            else -> collapsedTabTop(distance, expandedH)228                        }229                        val targetHeight = when {230                            isActive && deckOpen -> expandedH231                            isActive -> focusHeight232                            else -> TAB_CARD_H233                        }234                        val concealed = !deckOpen && !isActive235236                        // The drag-to-focus/collapse gesture is a continuous, interruptible237                        // user interaction (not a one-shot navigation transition), so it gets238                        // real spring physics here rather than the fixed-duration tween used239                        // for the one-shot celebration fan — matching PeachExpandableCard and240                        // current M3 Expressive's physics-based spatial-spring motion model.241                        val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)242                        val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)243                        val top by animateDpAsState(244                            targetTop,245                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,246                            label = "top",247                        )248                        val cardH by animateDpAsState(249                            targetHeight,250                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,251                            label = "h",252                        )253                        val alpha by animateFloatAsState(254                            if (concealed) 0f else 1f,255                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,256                            label = "a",257                        )258                        val zIndex = if (isActive) 99f else (50 - distance).toFloat()259260                        // Celebration shake — keyframed bob + tilt as the Pro card lands.261                        val shakeDy = remember { Animatable(0f) }262                        val shakeRot = remember { Animatable(0f) }263                        LaunchedEffect(celebrating == card.id) {264                            if (celebrating == card.id && !reduceMotion) {265                                launch {266                                    shakeRot.animateTo(0f, keyframes {267                                        durationMillis = 880268                                        0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757269                                    })270                                }271                                shakeDy.animateTo(0f, keyframes {272                                    durationMillis = 880273                                    0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757274                                })275                            }276                        }277278                        val density = LocalDensity.current279                        StackCard(280                            spec = card,281                            active = isActive,282                            focused = isActive && !deckOpen,283                            lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),284                            showSparkle = card.id == CardId.Pro && isPro,285                            onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,286                            body = {287                                CardBody(288                                    id = card.id,289                                    fg = card.fg,290                                    vm = vm,291                                    settings = settings,292                                    entriesCount = entries.count { it.deletedAt == null },293                                    onNavigate = onNavigate,294                                )295                            },296                            modifier = Modifier297                                .fillMaxWidth()298                                .align(Alignment.TopStart)299                                .zIndex(zIndex)300                                // Drag the deck like a physical stack: flick a tab up to focus301                                // it, drag the focused card down to drop back into the deck.302                                .pointerInput(card.id, deckOpen, isActive) {303                                    var dragY = 0f304                                    val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }305                                    detectDragGestures(306                                        onDragStart = { dragY = 0f },307                                        onDrag = { change, amount -> change.consume(); dragY += amount.y },308                                        onDragEnd = {309                                            when (resolveStackDrag(dragY, thresholdPx, isActive && !deckOpen)) {310                                                DragOutcome.EXPAND -> { active = card.id; deckOpen = false }311                                                DragOutcome.COLLAPSE -> { deckOpen = true }312                                                DragOutcome.NONE -> {}313                                            }314                                        },315                                    )316                                }317                                .graphicsLayer {318                                    translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }319                                    rotationZ = shakeRot.value320                                    this.alpha = alpha321                                }322                                .height(cardH),323                            onClick = {324                                // Tapping the active card is a no-op (browser StackDeck325                                // parity); flick it up to focus, a tab up to focus that one.326                                if (!isActive) {327                                    active = card.id328                                    deckOpen = false329                                }330                            },331                            onCollapseRequest = { deckOpen = true },332                            onExpandRequest = { active = card.id; deckOpen = false },333                            enabled = !concealed,334                        )335                    }336                }337            }338        }339        if (showCelebration) {340            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })341        }342    }343    }344}345346@Composable347private fun StackCard(348    spec: StackCardSpec,349    active: Boolean,350    focused: Boolean,351    lede: String,352    showSparkle: Boolean,353    onIconClick: (() -> Unit)?,354    body: @Composable () -> Unit,355    modifier: Modifier,356    onClick: () -> Unit,357    onCollapseRequest: () -> Unit,358    onExpandRequest: () -> Unit,359    enabled: Boolean,360) {361    val interaction = remember { MutableInteractionSource() }362    Box(363        modifier364            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)365            .clip(RoundedCornerShape(PeachStackColors.CardRadius))366            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }367            .clickable(368                interactionSource = interaction,369                indication = null,370                enabled = enabled && !active || (active && !focused),371            ) { onClick() }372            .padding(373                horizontal = if (active) 18.dp else 16.dp,374                vertical = if (active) 16.dp else 12.dp,375            ),376    ) {377        if (showSparkle) SparkleDust(Modifier.fillMaxSize())378        if (active) {379            Column(Modifier.fillMaxSize()) {380                Row(381                    Modifier.fillMaxWidth(),382                    horizontalArrangement = Arrangement.SpaceBetween,383                    verticalAlignment = Alignment.CenterVertically,384                ) {385                    Text(386                        spec.num,387                        color = spec.fg.copy(alpha = 0.7f),388                        fontFamily = PeachType.Mono,389                        fontSize = 8.5.sp,390                        letterSpacing = 0.26.em,391                    )392                    Box(393                        Modifier.clip(RoundedCornerShape(999.dp))394                            .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)395                            .padding(2.dp),396                    ) {397                        CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))398                    }399                }400                Text(401                    spec.title,402                    modifier = Modifier.padding(top = 6.dp),403                    color = spec.fg,404                    fontFamily = PeachType.Display,405                    fontStyle = FontStyle.Italic,406                    fontSize = 26.sp,407                )408                Text(409                    lede,410                    modifier = Modifier.padding(top = 4.dp),411                    color = spec.fg.copy(alpha = 0.72f),412                    fontFamily = PeachType.Display,413                    fontStyle = FontStyle.Italic,414                    fontSize = 12.5.sp,415                )416                // Body scrolls independently; only revealed at full height in417                // focus mode, but kept mounted so deck-mode shows a preview.418                // Two-way scroll gesture, mirrored: scrolling a collapsed-to-419                // preview card at all expands it straight away (a cramped420                // preview has nothing worth scrolling, so intercept in421                // onPreScroll before its own verticalScroll ever sees the422                // drag); scrolling a focused card past its own top hands the423                // leftover delta to the deck to collapse instead of letting424                // it disappear as a no-op overscroll.425                val scrollState = rememberScrollState()426                val density = LocalDensity.current427                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }428                // Read the latest focused/active through State rather than as429                // remember() keys: firing onCollapseRequest/onExpandRequest changes430                // `focused` mid-gesture, and recreating the connection object right431                // then would reset `latched` and immediately re-trigger the other432                // branch for the same still-moving finger.433                val latestFocused = rememberUpdatedState(focused)434                val latestActive = rememberUpdatedState(active)435                val nestedScrollConnection = remember(scrollState) {436                    object : NestedScrollConnection {437                        var expandAccum = 0f438                        var collapseAccum = 0f439                        // A collapse flips `focused` to false mid-gesture, which would440                        // otherwise immediately satisfy the expand branch below for the441                        // *same* still-moving finger (and vice versa), thrashing back442                        // and forth. Latch to one action per physical touch; unlatch443                        // only once the finger lifts (onPreFling fires exactly once444                        // per released gesture, fling or not).445                        var latched = false446                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {447                            val focused = latestFocused.value448                            val active = latestActive.value449                            if (latched || !active || focused || source != NestedScrollSource.Drag || available.y == 0f) {450                                expandAccum = 0f451                                return Offset.Zero452                            }453                            expandAccum += available.y454                            if (kotlin.math.abs(expandAccum) >= gestureThresholdPx) {455                                expandAccum = 0f456                                latched = true457                                onExpandRequest()458                            }459                            return available460                        }461                        override fun onPostScroll(462                            consumed: Offset,463                            available: Offset,464                            source: NestedScrollSource,465                        ): Offset {466                            val focused = latestFocused.value467                            if (latched || !focused || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {468                                collapseAccum = 0f469                                return Offset.Zero470                            }471                            collapseAccum += available.y472                            if (collapseAccum >= gestureThresholdPx) {473                                collapseAccum = 0f474                                latched = true475                                onCollapseRequest()476                            }477                            return Offset(0f, available.y)478                        }479                        override suspend fun onPreFling(available: Velocity): Velocity {480                            latched = false481                            return Velocity.Zero482                        }483                    }484                }485                Column(486                    Modifier.fillMaxWidth().weight(1f)487                        .padding(top = 14.dp)488                        .nestedScroll(nestedScrollConnection)489                        .verticalScroll(scrollState),490                ) {491                    body()492                    // Trailing breathing room so bottom-of-card content (e.g. the493                    // Pro card's CTA button) never sits flush against the fanned494                    // tab stacked directly beneath it.495                    Spacer(Modifier.height(PeachSpacing.xl))496                }497            }498        } else {499            // Tab: just the title, single line, vertically centred.500            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {501                Text(502                    spec.title,503                    color = spec.fg,504                    fontFamily = PeachType.Display,505                    fontStyle = FontStyle.Italic,506                    fontWeight = FontWeight.Normal,507                    fontSize = 19.sp,508                    maxLines = 1,509                    overflow = TextOverflow.Ellipsis,510                )511            }512        }513    }514}515516@Composable517private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {518    PeachTopBar(519        title = "Settings",520        leading = {521            Box(522                Modifier523                    .size(40.dp)524                    .clip(RoundedCornerShape(999.dp))525                    .clickable(onClick = onBack),526                contentAlignment = Alignment.Center,527            ) {528                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)529            }530        },531        actions = {532            Text(533                "${activeIndex + 1} / $total",534                color = peachColors.T2,535                fontFamily = PeachType.Mono,536                fontSize = 9.sp,537                letterSpacing = 0.22.em,538                modifier = Modifier.padding(horizontal = PeachSpacing.sm),539            )540        },541    )542}543544545/**546 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed547 * from the live draw size so the angle holds on any card height, unlike Compose's548 * default corner-to-corner linear gradient.549 */550private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {551    val a = Math.toRadians(155.0)552    val dx = kotlin.math.sin(a).toFloat()553    val dy = -kotlin.math.cos(a).toFloat()554    val cx = size.width / 2f555    val cy = size.height / 2f556    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f557    return Brush.linearGradient(558        colors = listOf(start, end),559        start = Offset(cx - dx * half, cy - dy * half),560        end = Offset(cx + dx * half, cy + dy * half),561    )562}563564/**565 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly566 * translate diagonally and loop, screen-blended at low opacity — the Compose567 * analogue of the browser's `stk-pro-dust` keyframes.568 */569@Composable570private fun SparkleDust(modifier: Modifier = Modifier) {571    val reduceMotion = LocalReduceMotion.current572    val t = rememberInfiniteTransition(label = "dust")573    val drift by if (reduceMotion) {574        remember { mutableStateOf(0f) }575    } else {576        t.animateFloat(577            initialValue = 0f,578            targetValue = 1f,579            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),580            label = "drift",581        )582    }583    Box(584        modifier.drawWithCache {585            val tile = 48.dp.toPx()586            val dx = drift * tile587            onDrawBehind {588                val cols = (size.width / tile).toInt() + 2589                val rows = (size.height / tile).toInt() + 2590                for (r in 0..rows) {591                    for (c in 0..cols) {592                        val baseX = c * tile + dx593                        val baseY = r * tile + dx594                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)595                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)596                    }597                }598            }599        },600    )601}