SettingsScreen.kt

29.0 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. Only wired for non-active tabs — the active card's own302                                // scrollable body handles its half of the gesture (expand from303                                // preview, collapse from focus) via StackCard's nested scroll304                                // connection below. Attaching this to the active card too used305                                // to fight that connection for the same touch: this pointerInput306                                // keys on `deckOpen`/`isActive`, which the nested-scroll actions307                                // mutate mid-gesture, restarting this detector with the finger308                                // still down and firing its own independent resolution — a309                                // visible flicker as two systems retargeted the spring in quick310                                // succession.311                                .then(312                                    if (!isActive) {313                                        Modifier.pointerInput(card.id, deckOpen) {314                                            var dragY = 0f315                                            val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }316                                            detectDragGestures(317                                                onDragStart = { dragY = 0f },318                                                onDrag = { change, amount -> change.consume(); dragY += amount.y },319                                                onDragEnd = {320                                                    if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {321                                                        active = card.id322                                                        deckOpen = false323                                                    }324                                                },325                                            )326                                        }327                                    } else {328                                        Modifier329                                    },330                                )331                                .graphicsLayer {332                                    translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }333                                    rotationZ = shakeRot.value334                                    this.alpha = alpha335                                }336                                .height(cardH),337                            onClick = {338                                // Tapping the active card is a no-op (browser StackDeck339                                // parity); flick it up to focus, a tab up to focus that one.340                                if (!isActive) {341                                    active = card.id342                                    deckOpen = false343                                }344                            },345                            onCollapseRequest = { deckOpen = true },346                            onExpandRequest = { active = card.id; deckOpen = false },347                            enabled = !concealed,348                        )349                    }350                }351            }352        }353        if (showCelebration) {354            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })355        }356    }357    }358}359360@Composable361private fun StackCard(362    spec: StackCardSpec,363    active: Boolean,364    focused: Boolean,365    lede: String,366    showSparkle: Boolean,367    onIconClick: (() -> Unit)?,368    body: @Composable () -> Unit,369    modifier: Modifier,370    onClick: () -> Unit,371    onCollapseRequest: () -> Unit,372    onExpandRequest: () -> Unit,373    enabled: Boolean,374) {375    val interaction = remember { MutableInteractionSource() }376    Box(377        modifier378            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)379            .clip(RoundedCornerShape(PeachStackColors.CardRadius))380            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }381            .clickable(382                interactionSource = interaction,383                indication = null,384                enabled = enabled && !active || (active && !focused),385            ) { onClick() }386            .padding(387                horizontal = if (active) 18.dp else 16.dp,388                vertical = if (active) 16.dp else 12.dp,389            ),390    ) {391        if (showSparkle) SparkleDust(Modifier.fillMaxSize())392        if (active) {393            Column(Modifier.fillMaxSize()) {394                Row(395                    Modifier.fillMaxWidth(),396                    horizontalArrangement = Arrangement.SpaceBetween,397                    verticalAlignment = Alignment.CenterVertically,398                ) {399                    Text(400                        spec.num,401                        color = spec.fg.copy(alpha = 0.7f),402                        fontFamily = PeachType.Mono,403                        fontSize = 8.5.sp,404                        letterSpacing = 0.26.em,405                    )406                    Box(407                        Modifier.clip(RoundedCornerShape(999.dp))408                            .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)409                            .padding(2.dp),410                    ) {411                        CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))412                    }413                }414                Text(415                    spec.title,416                    modifier = Modifier.padding(top = 6.dp),417                    color = spec.fg,418                    fontFamily = PeachType.Display,419                    fontStyle = FontStyle.Italic,420                    fontSize = 26.sp,421                )422                Text(423                    lede,424                    modifier = Modifier.padding(top = 4.dp),425                    color = spec.fg.copy(alpha = 0.72f),426                    fontFamily = PeachType.Display,427                    fontStyle = FontStyle.Italic,428                    fontSize = 12.5.sp,429                )430                // Body scrolls independently; only revealed at full height in431                // focus mode, but kept mounted so deck-mode shows a preview.432                // Two-way scroll gesture, mirrored: dragging up on a collapsed-433                // to-preview card expands it straight away (a cramped preview434                // has nothing worth scrolling, so intercept in onPreScroll435                // before its own verticalScroll ever sees the drag) — down-only436                // is deliberately excluded so the same downward motion that437                // just collapsed a card can't immediately re-open it. Dragging438                // a focused card down past its own scroll top hands the439                // leftover delta to the deck to collapse, instead of letting440                // it disappear as a no-op overscroll.441                val scrollState = rememberScrollState()442                val density = LocalDensity.current443                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }444                // Read the latest focused/active through State rather than as445                // remember() keys, so the connection object (and its accumulators)446                // survives recompositions the gesture's own resolution triggers.447                val latestFocused = rememberUpdatedState(focused)448                val latestActive = rememberUpdatedState(active)449                val nestedScrollConnection = remember(scrollState) {450                    object : NestedScrollConnection {451                        var expandAccum = 0f452                        var collapseAccum = 0f453                        // Resolved once on release (onPreFling), not continuously454                        // mid-drag — matching resolveStackDrag's on-release convention455                        // above. Firing mid-drag flipped `focused` while the finger456                        // was still delivering deltas from the same physical touch,457                        // which the OTHER stack-tab pointerInput (keyed on `deckOpen`)458                        // would also observe and restart on, independently resolving459                        // the same gesture a second time — a visible flicker.460                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {461                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {462                                return Offset.Zero463                            }464                            expandAccum += available.y465                            return available466                        }467                        override fun onPostScroll(468                            consumed: Offset,469                            available: Offset,470                            source: NestedScrollSource,471                        ): Offset {472                            if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {473                                return Offset.Zero474                            }475                            collapseAccum += available.y476                            return Offset(0f, available.y)477                        }478                        override suspend fun onPreFling(available: Velocity): Velocity {479                            // Upward drags only (matches the tab-flick-up convention480                            // used elsewhere in this deck). A downward drag repeated481                            // right after a collapse — muscle memory from "scroll to482                            // top to collapse" — would otherwise re-satisfy this on483                            // the now-unfocused preview and flicker straight back open.484                            if (expandAccum <= -gestureThresholdPx) {485                                onExpandRequest()486                            } else if (collapseAccum >= gestureThresholdPx) {487                                onCollapseRequest()488                            }489                            expandAccum = 0f490                            collapseAccum = 0f491                            return Velocity.Zero492                        }493                    }494                }495                Column(496                    Modifier.fillMaxWidth().weight(1f)497                        .padding(top = 14.dp)498                        .nestedScroll(nestedScrollConnection)499                        .verticalScroll(scrollState),500                ) {501                    body()502                    // Trailing breathing room so bottom-of-card content (e.g. the503                    // Pro card's CTA button) never sits flush against the fanned504                    // tab stacked directly beneath it.505                    Spacer(Modifier.height(PeachSpacing.xl))506                }507            }508        } else {509            // Tab: just the title, single line, vertically centred.510            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {511                Text(512                    spec.title,513                    color = spec.fg,514                    fontFamily = PeachType.Display,515                    fontStyle = FontStyle.Italic,516                    fontWeight = FontWeight.Normal,517                    fontSize = 19.sp,518                    maxLines = 1,519                    overflow = TextOverflow.Ellipsis,520                )521            }522        }523    }524}525526@Composable527private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {528    PeachTopBar(529        title = "Settings",530        leading = {531            Box(532                Modifier533                    .size(40.dp)534                    .clip(RoundedCornerShape(999.dp))535                    .clickable(onClick = onBack),536                contentAlignment = Alignment.Center,537            ) {538                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)539            }540        },541        actions = {542            Text(543                "${activeIndex + 1} / $total",544                color = peachColors.T2,545                fontFamily = PeachType.Mono,546                fontSize = 9.sp,547                letterSpacing = 0.22.em,548                modifier = Modifier.padding(horizontal = PeachSpacing.sm),549            )550        },551    )552}553554555/**556 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed557 * from the live draw size so the angle holds on any card height, unlike Compose's558 * default corner-to-corner linear gradient.559 */560private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {561    val a = Math.toRadians(155.0)562    val dx = kotlin.math.sin(a).toFloat()563    val dy = -kotlin.math.cos(a).toFloat()564    val cx = size.width / 2f565    val cy = size.height / 2f566    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f567    return Brush.linearGradient(568        colors = listOf(start, end),569        start = Offset(cx - dx * half, cy - dy * half),570        end = Offset(cx + dx * half, cy + dy * half),571    )572}573574/**575 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly576 * translate diagonally and loop, screen-blended at low opacity — the Compose577 * analogue of the browser's `stk-pro-dust` keyframes.578 */579@Composable580private fun SparkleDust(modifier: Modifier = Modifier) {581    val reduceMotion = LocalReduceMotion.current582    val t = rememberInfiniteTransition(label = "dust")583    val drift by if (reduceMotion) {584        remember { mutableStateOf(0f) }585    } else {586        t.animateFloat(587            initialValue = 0f,588            targetValue = 1f,589            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),590            label = "drift",591        )592    }593    Box(594        modifier.drawWithCache {595            val tile = 48.dp.toPx()596            val dx = drift * tile597            onDrawBehind {598                val cols = (size.width / tile).toInt() + 2599                val rows = (size.height / tile).toInt() + 2600                for (r in 0..rows) {601                    for (c in 0..cols) {602                        val baseX = c * tile + dx603                        val baseY = r * tile + dx604                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)605                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)606                    }607                }608            }609        },610    )611}