8ec8a51c5f29

eliminate the real remaining settings-card gesture flicker and fix launcher icon safe-zone/background - root cause (found by a dedicated investigation pass) was two independent gesture recognizers on the same focused card fighting over one touch stream: the outer tab-level detectDragGestures was still attached to the active card too, keyed on deckOpen/isActive, which the nested-scroll actions mutate mid-drag - the instant collapse fired, that pointerInput's key changed, cancelling and restarting the coroutine with the finger still down, letting it independently resolve the same physical gesture a second time. Fixed by restricting the outer detector to non-active tabs only (the active card's own nested scroll connection now owns 100% of its gesture) and moving both actions to resolve once on release (onPreFling) instead of continuously mid-drag, matching the app's existing resolveStackDrag on-release convention. Also restricted the expand trigger to upward drags only (previously any direction) after live testing showed repeated downward 'scroll to top' attempts right after a collapse would immediately re-satisfy an any-direction check on the now-unfocused preview and flicker back open. Separately: the launcher adaptive-icon background went through two more corrections after the first transparent attempt rendered as an empty black void on this launcher instead of blending with wallpaper (adaptive-icon backgrounds are expected to be opaque) - now filled with the app's own root canvas color (PeachColors.Bg, #0C0A06) instead of the original literal cream or a naive transparent; and the foreground/monochrome peach mark's safe-zone scale was corrected from an initial 0.8 (based on overall bbox, still let the leaf tip clip on circular masks) to 0.6 after computing the leaf's true radial distance from center (~126 of 240 viewport units, not just its bbox extent). All live-verified on-device: full expand/collapse/re-expand cycle clean with no flicker, repeated downward drag after collapse no longer reopens, launcher icon shows the complete un-cropped peach on a color-filled background in the app drawer dock.

time
2026-07-24 12:42 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
critical-corrections-c1-c7
parent
55352aec7f22
4 files app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+61 −51app/src/main/res/drawable/ic_launcher_background.xml+5 −1app/src/main/res/drawable/ic_launcher_foreground.xml+31 −21app/src/main/res/drawable/ic_launcher_monochrome.xml+28 −21
app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+61 −51
@@ -298,22 +298,36 @@298298                                 .align(Alignment.TopStart)299299                                 .zIndex(zIndex)300300                                 // 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-                                }301+                                // 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+                                )317331                                 .graphicsLayer {318332                                     translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }319333                                     rotationZ = shakeRot.value@@ -415,47 +429,39 @@415429                 )416430                 // Body scrolls independently; only revealed at full height in417431                 // 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 letting432+                // 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 letting424440                 // it disappear as a no-op overscroll.425441                 val scrollState = rememberScrollState()426442                 val density = LocalDensity.current427443                 val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }428444                 // 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.445+                // remember() keys, so the connection object (and its accumulators)446+                // survives recompositions the gesture's own resolution triggers.433447                 val latestFocused = rememberUpdatedState(focused)434448                 val latestActive = rememberUpdatedState(active)435449                 val nestedScrollConnection = remember(scrollState) {436450                     object : NestedScrollConnection {437451                         var expandAccum = 0f438452                         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 = false453+                        // 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.446460                         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 = 0f461+                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {451462                                 return Offset.Zero452463                             }453464                             expandAccum += available.y454-                            if (kotlin.math.abs(expandAccum) >= gestureThresholdPx) {455-                                expandAccum = 0f456-                                latched = true457-                                onExpandRequest()458-                            }459465                             return available460466                         }461467                         override fun onPostScroll(@@ -463,21 +469,25 @@463469                             available: Offset,464470                             source: NestedScrollSource,465471                         ): Offset {466-                            val focused = latestFocused.value467-                            if (latched || !focused || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {468-                                collapseAccum = 0f472+                            if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {469473                                 return Offset.Zero470474                             }471475                             collapseAccum += available.y472-                            if (collapseAccum >= gestureThresholdPx) {473-                                collapseAccum = 0f474-                                latched = true475-                                onCollapseRequest()476-                            }477476                             return Offset(0f, available.y)478477                         }479478                         override suspend fun onPreFling(available: Velocity): Velocity {480-                            latched = false479+                            // 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 = 0f481491                             return Velocity.Zero482492                         }483493                     }

view file

app/src/main/res/drawable/ic_launcher_background.xml+5 −1
@@ -1,4 +1,8 @@11 <?xml version="1.0" encoding="utf-8"?>22 <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">3-    <solid android:color="@android:color/transparent" />3+    <!-- Transparent read as an empty black void on some launchers instead of4+         blending with the wallpaper — adaptive-icon backgrounds are expected5+         to be opaque. Match the app's own root canvas (PeachColors.Bg)6+         instead of the old literal cream/white. -->7+    <solid android:color="#FF0C0A06" />48 </shape>

view file

app/src/main/res/drawable/ic_launcher_foreground.xml+31 −21
@@ -4,28 +4,38 @@44     android:height="108dp"55     android:viewportWidth="240"66     android:viewportHeight="240">7-    <path8-        android:pathData="M124 64 C134 32 168 16 200 22 C198 56 168 72 130 66 Z"9-        android:fillColor="#92AC65" />10-    <path11-        android:pathData="M130 64 C152 46 178 30 196 24"12-        android:strokeColor="#5E7A42"13-        android:strokeWidth="2.6"14-        android:fillColor="@android:color/transparent"15-        android:strokeLineCap="round" />16-    <path17-        android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C185 222 222 185 220 130 C218 78 170 45 120 60 Z"18-        android:fillColor="#FFB082" />19-    <path20-        android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C105 195 75 150 100 90 C105 75 112 65 120 60 Z"21-        android:fillColor="#E5805C" />7+    <!-- Adaptive-icon safe zone: circular/squircle launcher masks crop to a8+         circle roughly 60% of the canvas radius. The leaf tip is the true9+         outlier (~126 units from center, not just the overall bbox), so it10+         needs ~0.6 scale, not the ~0.8 an edge-to-edge bbox read suggests. -->2211     <group23-        android:pivotX="178"24-        android:pivotY="108"25-        android:rotation="-26">12+        android:pivotX="120"13+        android:pivotY="120"14+        android:scaleX="0.6"15+        android:scaleY="0.6">2616         <path27-            android:pathData="M198 108 A20 11 0 1 0 158 108 A20 11 0 1 0 198 108 Z"28-            android:fillColor="#FFD0A8"29-            android:fillAlpha="0.7" />17+            android:pathData="M124 64 C134 32 168 16 200 22 C198 56 168 72 130 66 Z"18+            android:fillColor="#92AC65" />19+        <path20+            android:pathData="M130 64 C152 46 178 30 196 24"21+            android:strokeColor="#5E7A42"22+            android:strokeWidth="2.6"23+            android:fillColor="@android:color/transparent"24+            android:strokeLineCap="round" />25+        <path26+            android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C185 222 222 185 220 130 C218 78 170 45 120 60 Z"27+            android:fillColor="#FFB082" />28+        <path29+            android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C105 195 75 150 100 90 C105 75 112 65 120 60 Z"30+            android:fillColor="#E5805C" />31+        <group32+            android:pivotX="178"33+            android:pivotY="108"34+            android:rotation="-26">35+            <path36+                android:pathData="M198 108 A20 11 0 1 0 158 108 A20 11 0 1 0 198 108 Z"37+                android:fillColor="#FFD0A8"38+                android:fillAlpha="0.7" />39+        </group>3040     </group>3141 </vector>

view file

app/src/main/res/drawable/ic_launcher_monochrome.xml+28 −21
@@ -4,28 +4,35 @@44     android:height="108dp"55     android:viewportWidth="240"66     android:viewportHeight="240">7-    <path8-        android:pathData="M124 64 C134 32 168 16 200 22 C198 56 168 72 130 66 Z"9-        android:fillColor="#000000" />10-    <path11-        android:pathData="M130 64 C152 46 178 30 196 24"12-        android:strokeColor="#000000"13-        android:strokeWidth="2.6"14-        android:fillColor="@android:color/transparent"15-        android:strokeLineCap="round" />16-    <path17-        android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C185 222 222 185 220 130 C218 78 170 45 120 60 Z"18-        android:fillColor="#000000" />19-    <path20-        android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C105 195 75 150 100 90 C105 75 112 65 120 60 Z"21-        android:fillColor="#000000" />7+    <!-- Keep in sync with ic_launcher_foreground.xml's safe-zone inset. -->228     <group23-        android:pivotX="178"24-        android:pivotY="108"25-        android:rotation="-26">9+        android:pivotX="120"10+        android:pivotY="120"11+        android:scaleX="0.6"12+        android:scaleY="0.6">2613         <path27-            android:pathData="M198 108 A20 11 0 1 0 158 108 A20 11 0 1 0 198 108 Z"28-            android:fillColor="#000000"29-            android:fillAlpha="0.7" />14+            android:pathData="M124 64 C134 32 168 16 200 22 C198 56 168 72 130 66 Z"15+            android:fillColor="#000000" />16+        <path17+            android:pathData="M130 64 C152 46 178 30 196 24"18+            android:strokeColor="#000000"19+            android:strokeWidth="2.6"20+            android:fillColor="@android:color/transparent"21+            android:strokeLineCap="round" />22+        <path23+            android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C185 222 222 185 220 130 C218 78 170 45 120 60 Z"24+            android:fillColor="#000000" />25+        <path26+            android:pathData="M120 60 C75 45 28 75 22 130 C18 185 65 222 125 222 C105 195 75 150 100 90 C105 75 112 65 120 60 Z"27+            android:fillColor="#000000" />28+        <group29+            android:pivotX="178"30+            android:pivotY="108"31+            android:rotation="-26">32+            <path33+                android:pathData="M198 108 A20 11 0 1 0 158 108 A20 11 0 1 0 198 108 Z"34+                android:fillColor="#000000"35+                android:fillAlpha="0.7" />36+        </group>3037     </group>3138 </vector>

view file