From ddb94828ff8aa1a5522be915094005529e9eaa9d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 12:21:04 -0400 Subject: [PATCH 1/6] feat(chat): blur and fade the transcript at the top edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat bar drew a two-stop fade that was already ~70% transparent where the status bar sits, so a cash bubble scrolling under it showed through behind the clock with a hard cut at the bar's edge. Match iOS, which does this in two parts for a reason it documents: the soft scroll-edge effect blurs what passes under the bar but does not darken it, and a cash card's amount is large white text that still reads over the title once blurred. So the bar now applies a progressive `hazeBlur` (intensity 1 at the status bar, 0 by the tail's end) under a background gradient that holds fully opaque to `statusBars - 12.dp` before fading out — the same hold iOS uses for `TranscriptTopFade`. The blur style carries no tint of its own; the gradient painted over it is the whole of the darkening, and a material's wash on top would double the scrim where the two overlap. --- .../internal/screens/MessengerScreen.kt | 10 ++- .../internal/screens/components/ChatTopBar.kt | 68 ++++++++++++++++++- .../internal/ChatIdentityScreenshotTest.kt | 2 + .../ChatMessageActionScreenshotTest.kt | 2 + .../internal/ChatSelectionBarTest.kt | 2 + 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 3c6aea68c1..5f6531eb45 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -125,7 +125,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // The list runs the full height and passes under both bars, each of which fades it out // against the background at its own edge. barPlacement = ScaffoldBarPlacement.Overlay, - topBar = { ChatTopBar(navigator, state, chatActionHandler, viewModel::dispatchEvent) }, + topBar = { + ChatTopBar( + navigator = navigator, + state = state, + hazeState = hazeState, + chatActionHandler = chatActionHandler, + dispatch = viewModel::dispatchEvent, + ) + }, bottomBar = { UserControlBottomBar( state = state, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index fbeed4bd09..7e5a4d0707 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -9,9 +9,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack @@ -36,6 +39,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.ChatViewModel @@ -54,29 +58,45 @@ import com.getcode.ui.core.measured import com.getcode.ui.core.unboundedClickable import com.getcode.ui.utils.KeyboardController import com.getcode.ui.utils.rememberKeyboardController +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.hazeBlur @Composable internal fun ChatTopBar( navigator: CodeNavigator, state: ChatViewModel.State, + hazeState: HazeState, chatActionHandler: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, ) { var titleHeight by remember { mutableStateOf(0.dp) } val bgColor = CodeTheme.colors.background + val statusBars = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() // Held here rather than in the selection bar: KeyboardController.visible only starts tracking // from the composition it is created in, and the bar is composed after a long-press that leaves // the IME already up — a controller created there would read it as hidden. val keyboard = rememberKeyboardController() Box { + val edgeHeight = titleHeight + ChatTopEdge.Tail Box( modifier = Modifier .fillMaxWidth() - .height(titleHeight + 24.dp) + .height(edgeHeight) + // Blur first, colour over it: the blur is what stops the transcript being cut at a + // hard line under the bar, and the fade is what keeps a cash card's large white + // amount — still legible once blurred — from reading over the title. + .hazeBlur( + input = HazeInput.Sources(hazeState), + style = ChatTopEdge.blurStyle(bgColor), + ) .background( Brush.verticalGradient( colorStops = arrayOf( 0f to bgColor, + ChatTopEdge.opaqueStop(statusBars, edgeHeight) to bgColor, 1f to Color.Transparent, ) ) @@ -107,6 +127,52 @@ internal fun ChatTopBar( } } +/** + * How the transcript meets the bar: a progressive blur under it, and a fade to the background + * colour over that. + * + * Both halves are carried over from iOS, where the bar has no background of its own — the soft + * scroll-edge effect blurs what passes under it, and `TranscriptTopFade` takes that content to the + * background colour, because blur alone leaves a cash card's amount readable over the title. + */ +private object ChatTopEdge { + + /** How far the effect runs past the bar's own height. */ + val Tail = 24.dp + + /** + * How far short of the status bar's bottom edge the fade starts, so the strip behind the clock + * and battery is solid background rather than a nearly-opaque scrim with a bubble under it. + */ + private val OpaqueInsetTrim = 12.dp + + /** Matched to the bottom bar's `ultraThin` material, so both edges soften by the same amount. */ + private val BlurRadius = 20.dp + + /** The fraction of [edgeHeight] that stays fully opaque before the gradient starts. */ + fun opaqueStop(statusBars: Dp, edgeHeight: Dp): Float = when { + edgeHeight <= 0.dp -> 0f + else -> ((statusBars - OpaqueInsetTrim) / edgeHeight).coerceIn(0f, 1f) + } + + /** + * Blur only — no tint. The gradient painted over this is the whole of the darkening, and a + * material's own wash on top of it would double the scrim where they overlap. + */ + fun blurStyle(containerColor: Color): HazeBlurStyle = HazeBlurStyle { + backgroundColor(containerColor) + blurRadius(BlurRadius) + // Strongest against the status bar and gone by the tail's end, so the transcript arrives at + // the bar already soft instead of crossing a line. + progressive( + HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) + ) + } +} + /** What the bar is showing. The payload rides along so a crossfade-out still has it. */ private sealed interface TopBarMode { data object Conversation : TopBarMode diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 588acd3ab8..8995da22ef 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -29,6 +29,7 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import java.io.File import kotlin.time.Instant +import dev.chrisbanes.haze.rememberHazeState /** * Renders the DM info card in each identity state to a PNG so the name-or-handle rule can be @@ -115,6 +116,7 @@ class ChatIdentityScreenshotTest { ChatType.CONTACT_DM }, ), + hazeState = rememberHazeState(), chatActionHandler = {}, dispatch = {}, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt index aba0aacecd..1f125dba9f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt @@ -26,6 +26,7 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import java.io.File import kotlin.time.Instant +import dev.chrisbanes.haze.rememberHazeState /** * Renders the selection bar in each capability shape to a PNG so the bar's layout can be checked @@ -98,6 +99,7 @@ class ChatMessageActionScreenshotTest { chatType = ChatType.CONTACT_DM, selection = selection, ), + hazeState = rememberHazeState(), chatActionHandler = {}, dispatch = {}, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt index e118ec2a80..81e0000b0b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt @@ -23,6 +23,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import kotlin.time.Instant +import dev.chrisbanes.haze.rememberHazeState /** * What the selection bar offers is a function of the selected bubble's capabilities alone. A cash @@ -59,6 +60,7 @@ class ChatSelectionBarTest { chatType = ChatType.CONTACT_DM, selection = cash, ), + hazeState = rememberHazeState(), chatActionHandler = {}, dispatch = {}, ) From 1aa74ff607a95dec8079f634bbca50d4aeaa7e80 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:12:44 -0400 Subject: [PATCH 2/6] refactor(chat): fade the transcript's top edge without a blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `hazeBlur` from the top edge and let the gradient carry it. The transcript keeps its `hazeSource` for the bottom bar, so this removes the second blur pass over a scrolling list rather than the dependency. Compose 1.12.0 has no first-party backdrop API — there is no `Backdrop` type in `ui`, `ui-graphics`, `foundation` or `material3` — so the alternative to Haze was hand-rolling the same record-and-replay against `RenderEffect`, which is API 31+ and would fall back to this gradient on minSdk 29 anyway. Without the blur the fade has to do all of the hiding, so its stops are keyed to layout landmarks rather than to fractions of the region: opaque to `statusBars - 12.dp`, still at 0.9 alpha where the bar's own bottom edge is, and clear by the end of a 40dp tail. Keying the middle stop to the measured bar height means the taller selection and editing bars darken over their whole height instead of running out partway down, and spending the whole ramp on the tail keeps its end from reading as a band across whatever bubble it lands on. --- .../internal/screens/MessengerScreen.kt | 1 - .../internal/screens/components/ChatTopBar.kt | 88 +++++++++---------- .../internal/ChatIdentityScreenshotTest.kt | 4 +- .../ChatMessageActionScreenshotTest.kt | 4 +- .../internal/ChatSelectionBarTest.kt | 4 +- 5 files changed, 43 insertions(+), 58 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 5f6531eb45..457e6ce336 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -129,7 +129,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ChatTopBar( navigator = navigator, state = state, - hazeState = hazeState, chatActionHandler = chatActionHandler, dispatch = viewModel::dispatchEvent, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 7e5a4d0707..15a592be4c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -58,17 +58,11 @@ import com.getcode.ui.core.measured import com.getcode.ui.core.unboundedClickable import com.getcode.ui.utils.KeyboardController import com.getcode.ui.utils.rememberKeyboardController -import dev.chrisbanes.haze.HazeInput -import dev.chrisbanes.haze.HazeProgressive -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeBlurStyle -import dev.chrisbanes.haze.blur.hazeBlur @Composable internal fun ChatTopBar( navigator: CodeNavigator, state: ChatViewModel.State, - hazeState: HazeState, chatActionHandler: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, ) { @@ -85,22 +79,7 @@ internal fun ChatTopBar( modifier = Modifier .fillMaxWidth() .height(edgeHeight) - // Blur first, colour over it: the blur is what stops the transcript being cut at a - // hard line under the bar, and the fade is what keeps a cash card's large white - // amount — still legible once blurred — from reading over the title. - .hazeBlur( - input = HazeInput.Sources(hazeState), - style = ChatTopEdge.blurStyle(bgColor), - ) - .background( - Brush.verticalGradient( - colorStops = arrayOf( - 0f to bgColor, - ChatTopEdge.opaqueStop(statusBars, edgeHeight) to bgColor, - 1f to Color.Transparent, - ) - ) - ) + .background(ChatTopEdge.brush(bgColor, statusBars, titleHeight, edgeHeight)) ) // A message action takes the bar over rather than stacking a second one over it, so the // conversation's own actions can't be reached while one is pending. The takeover holds @@ -128,17 +107,21 @@ internal fun ChatTopBar( } /** - * How the transcript meets the bar: a progressive blur under it, and a fade to the background - * colour over that. + * How the transcript meets the bar: a fade to the background colour, opaque behind the status bar + * and clear by the end of the tail. * - * Both halves are carried over from iOS, where the bar has no background of its own — the soft - * scroll-edge effect blurs what passes under it, and `TranscriptTopFade` takes that content to the - * background colour, because blur alone leaves a cash card's amount readable over the title. + * Carried over from iOS's `TranscriptTopFade`, which exists because the bar has no background of + * its own. A cash card's amount is large white text, so without this it reads over the title and up + * into the status bar. */ private object ChatTopEdge { - /** How far the effect runs past the bar's own height. */ - val Tail = 24.dp + /** + * How far the fade runs past the bar's own height. The whole of the ramp from [BarEdgeAlpha] to + * transparent is spent here, so it is long enough that the ramp's own end doesn't read as a + * band across whatever bubble it lands on. + */ + val Tail = 40.dp /** * How far short of the status bar's bottom edge the fade starts, so the strip behind the clock @@ -146,31 +129,40 @@ private object ChatTopEdge { */ private val OpaqueInsetTrim = 12.dp - /** Matched to the bottom bar's `ultraThin` material, so both edges soften by the same amount. */ - private val BlurRadius = 20.dp - - /** The fraction of [edgeHeight] that stays fully opaque before the gradient starts. */ - fun opaqueStop(statusBars: Dp, edgeHeight: Dp): Float = when { - edgeHeight <= 0.dp -> 0f - else -> ((statusBars - OpaqueInsetTrim) / edgeHeight).coerceIn(0f, 1f) - } + /** + * How much scrim is left where the bar's own bottom edge is. Nearly all of it: a linear ramp + * across the whole region is down to roughly a third by the title's baseline, and white bubble + * text reads straight through. The ramp to transparent is spent almost entirely on [Tail], + * below the bar, where nothing has to stay legible. + */ + private const val BarEdgeAlpha = 0.9f /** - * Blur only — no tint. The gradient painted over this is the whole of the darkening, and a - * material's own wash on top of it would double the scrim where they overlap. + * Opaque behind the status bar, still mostly opaque where the bar ends, clear by the end of the + * tail. The middle stop is keyed to the bar's own height rather than a fraction of the ramp, so + * a taller bar — the selection and editing modes — darkens over its whole height rather than + * running out partway down. */ - fun blurStyle(containerColor: Color): HazeBlurStyle = HazeBlurStyle { - backgroundColor(containerColor) - blurRadius(BlurRadius) - // Strongest against the status bar and gone by the tail's end, so the transcript arrives at - // the bar already soft instead of crossing a line. - progressive( - HazeProgressive.verticalGradient( - startIntensity = 1f, - endIntensity = 0f, + fun brush(containerColor: Color, statusBars: Dp, titleHeight: Dp, edgeHeight: Dp): Brush { + val hold = opaqueStop(statusBars, edgeHeight) + val barEdge = fraction(titleHeight, edgeHeight).coerceIn(hold, 1f) + return Brush.verticalGradient( + colorStops = arrayOf( + 0f to containerColor, + hold to containerColor, + barEdge to containerColor.copy(alpha = BarEdgeAlpha), + 1f to Color.Transparent, ) ) } + + /** The fraction of [edgeHeight] that stays fully opaque before the gradient starts. */ + private fun opaqueStop(statusBars: Dp, edgeHeight: Dp): Float = + fraction(statusBars - OpaqueInsetTrim, edgeHeight) + + /** [of] as a fraction of [total], clamped, and 0 before the bar has been measured. */ + private fun fraction(of: Dp, total: Dp): Float = + if (total <= 0.dp) 0f else (of / total).coerceIn(0f, 1f) } /** What the bar is showing. The payload rides along so a crossfade-out still has it. */ diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 8995da22ef..13cb23bc2a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -29,7 +29,6 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import java.io.File import kotlin.time.Instant -import dev.chrisbanes.haze.rememberHazeState /** * Renders the DM info card in each identity state to a PNG so the name-or-handle rule can be @@ -116,8 +115,7 @@ class ChatIdentityScreenshotTest { ChatType.CONTACT_DM }, ), - hazeState = rememberHazeState(), - chatActionHandler = {}, + chatActionHandler = {}, dispatch = {}, ) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt index 1f125dba9f..ec0306c903 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt @@ -26,7 +26,6 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import java.io.File import kotlin.time.Instant -import dev.chrisbanes.haze.rememberHazeState /** * Renders the selection bar in each capability shape to a PNG so the bar's layout can be checked @@ -99,8 +98,7 @@ class ChatMessageActionScreenshotTest { chatType = ChatType.CONTACT_DM, selection = selection, ), - hazeState = rememberHazeState(), - chatActionHandler = {}, + chatActionHandler = {}, dispatch = {}, ) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt index 81e0000b0b..d06f40c7f3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt @@ -23,7 +23,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import kotlin.time.Instant -import dev.chrisbanes.haze.rememberHazeState /** * What the selection bar offers is a function of the selected bubble's capabilities alone. A cash @@ -60,8 +59,7 @@ class ChatSelectionBarTest { chatType = ChatType.CONTACT_DM, selection = cash, ), - hazeState = rememberHazeState(), - chatActionHandler = {}, + chatActionHandler = {}, dispatch = {}, ) } From 4e973c9e94faf9fdd9797d616cfe8546a5356d41 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:28:17 -0400 Subject: [PATCH 3/6] refactor(chat): match iOS's top edge, blurring without Haze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fade-only version had to be strong enough to hide content outright, because a cash card's amount is large white text that stays legible through anything short of an opaque scrim. iOS keeps its fade weak — alpha lands near a third behind the title and is spent before the bar's bottom edge — and gets away with it because `topEdgeEffect.style = .soft` blurs what scrolls underneath. So take iOS's numbers for the fade and supply the blur separately. Compose 1.12 has no backdrop API, so the blur is a `RenderEffect` chain: a blurred copy of the transcript masked to the top of it and composited over the sharp original. The mask is opaque across the bar's measured height and falls off over a 24dp tail, matching how the iOS effect covers its region evenly and softens only at the boundary — a mask that ramps from the very top instead leaves the title's depth barely blurred, which is where the collision was in the first place. `RenderEffect` needs API 31; below that the fade stands alone, which still keeps an amount out of the status bar. The bar reports its measured height up so the transcript can size the blur to it, since selection and editing modes change that height. Scroll cost is nothing measurable: p50 18ms and p90 32ms with the blur against 18ms and 32ms without it, on the same transcript. --- .../internal/screens/MessengerScreen.kt | 11 ++ .../internal/screens/components/ChatTopBar.kt | 72 ++-------- .../screens/components/ChatTopEdge.kt | 133 ++++++++++++++++++ .../internal/ChatIdentityScreenshotTest.kt | 3 +- .../ChatMessageActionScreenshotTest.kt | 3 +- .../internal/ChatSelectionBarTest.kt | 3 +- 6 files changed, 158 insertions(+), 67 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 457e6ce336..2fefbf828b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -5,14 +5,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge +import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.flipcash.shared.chat.models.ChatAction @@ -31,6 +37,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val navigator = LocalCodeNavigator.current val hazeState = rememberHazeState() + // Measured by the bar and read by the transcript: the blur has to cover the bar's own + // height, which the selection and editing modes change. + var barHeight by remember { mutableStateOf(0.dp) } val keyboard = rememberKeyboardController() val chatActionHandler = { action: ChatAction -> @@ -129,6 +138,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ChatTopBar( navigator = navigator, state = state, + onBarHeightChange = { barHeight = it }, chatActionHandler = chatActionHandler, dispatch = viewModel::dispatchEvent, ) @@ -145,6 +155,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { modifier = Modifier .fillMaxSize() .testTag("chat_message_list") + .softTopEdge(ChatTopEdge.blurHold(barHeight)) .hazeSource(hazeState), state = state, contentPadding = overlapPadding, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 15a592be4c..1d761e4544 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag @@ -63,6 +62,7 @@ import com.getcode.ui.utils.rememberKeyboardController internal fun ChatTopBar( navigator: CodeNavigator, state: ChatViewModel.State, + onBarHeightChange: (Dp) -> Unit, chatActionHandler: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, ) { @@ -74,12 +74,12 @@ internal fun ChatTopBar( // the IME already up — a controller created there would read it as hidden. val keyboard = rememberKeyboardController() Box { - val edgeHeight = titleHeight + ChatTopEdge.Tail + val fadeHeight = ChatTopEdge.fadeHeight(titleHeight) Box( modifier = Modifier .fillMaxWidth() - .height(edgeHeight) - .background(ChatTopEdge.brush(bgColor, statusBars, titleHeight, edgeHeight)) + .height(fadeHeight) + .background(ChatTopEdge.fadeBrush(bgColor, statusBars, fadeHeight)) ) // A message action takes the bar over rather than stacking a second one over it, so the // conversation's own actions can't be reached while one is pending. The takeover holds @@ -91,7 +91,10 @@ internal fun ChatTopBar( else -> TopBarMode.Conversation } AnimatedContent( - modifier = Modifier.measured { titleHeight = it.height }, + modifier = Modifier.measured { + titleHeight = it.height + onBarHeightChange(it.height) + }, targetState = mode, contentKey = { it::class }, transitionSpec = { fadeIn() togetherWith fadeOut() }, @@ -106,65 +109,6 @@ internal fun ChatTopBar( } } -/** - * How the transcript meets the bar: a fade to the background colour, opaque behind the status bar - * and clear by the end of the tail. - * - * Carried over from iOS's `TranscriptTopFade`, which exists because the bar has no background of - * its own. A cash card's amount is large white text, so without this it reads over the title and up - * into the status bar. - */ -private object ChatTopEdge { - - /** - * How far the fade runs past the bar's own height. The whole of the ramp from [BarEdgeAlpha] to - * transparent is spent here, so it is long enough that the ramp's own end doesn't read as a - * band across whatever bubble it lands on. - */ - val Tail = 40.dp - - /** - * How far short of the status bar's bottom edge the fade starts, so the strip behind the clock - * and battery is solid background rather than a nearly-opaque scrim with a bubble under it. - */ - private val OpaqueInsetTrim = 12.dp - - /** - * How much scrim is left where the bar's own bottom edge is. Nearly all of it: a linear ramp - * across the whole region is down to roughly a third by the title's baseline, and white bubble - * text reads straight through. The ramp to transparent is spent almost entirely on [Tail], - * below the bar, where nothing has to stay legible. - */ - private const val BarEdgeAlpha = 0.9f - - /** - * Opaque behind the status bar, still mostly opaque where the bar ends, clear by the end of the - * tail. The middle stop is keyed to the bar's own height rather than a fraction of the ramp, so - * a taller bar — the selection and editing modes — darkens over its whole height rather than - * running out partway down. - */ - fun brush(containerColor: Color, statusBars: Dp, titleHeight: Dp, edgeHeight: Dp): Brush { - val hold = opaqueStop(statusBars, edgeHeight) - val barEdge = fraction(titleHeight, edgeHeight).coerceIn(hold, 1f) - return Brush.verticalGradient( - colorStops = arrayOf( - 0f to containerColor, - hold to containerColor, - barEdge to containerColor.copy(alpha = BarEdgeAlpha), - 1f to Color.Transparent, - ) - ) - } - - /** The fraction of [edgeHeight] that stays fully opaque before the gradient starts. */ - private fun opaqueStop(statusBars: Dp, edgeHeight: Dp): Float = - fraction(statusBars - OpaqueInsetTrim, edgeHeight) - - /** [of] as a fraction of [total], clamped, and 0 before the bar has been measured. */ - private fun fraction(of: Dp, total: Dp): Float = - if (total <= 0.dp) 0f else (of / total).coerceIn(0f, 1f) -} - /** What the bar is showing. The payload rides along so a crossfade-out still has it. */ private sealed interface TopBarMode { data object Conversation : TopBarMode diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt new file mode 100644 index 0000000000..de574e9669 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt @@ -0,0 +1,133 @@ +package com.flipcash.app.messenger.internal.screens.components + +import android.graphics.LinearGradient +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import android.graphics.BlendMode as AndroidBlendMode +import android.graphics.Color as AndroidColor + +/** + * How the transcript meets the top bar: the content passing under it is blurred, and a gradient over + * that takes it to the background colour. + * + * Both halves are carried over from iOS, where the bar has no background of its own. The soft + * scroll-edge effect blurs what scrolls under it but does not darken it, and a cash card's amount is + * large white text — blurred, it still reads over the title — so `TranscriptTopFade` supplies the + * darkening. Neither half works alone: without the blur the fade has to be strong enough to hide + * content outright, and without the fade the amount stays legible. + */ +internal object ChatTopEdge { + + /** + * How far above the bar's bottom edge the fade finishes. iOS clears 8pt above a 44pt bar; the + * point is that the fade is spent by the time the title is passed, not that content is hidden + * all the way down. The blur carries the last of the transition. + */ + private val FadeEndInset = 10.dp + + /** + * How far short of the status bar's bottom edge the fade starts, so the strip behind the clock + * and battery is solid background rather than a nearly-opaque scrim with a bubble under it. + * iOS's `opaqueLength`, verbatim. + */ + private val OpaqueInsetTrim = 12.dp + + /** + * How far past the bar the blur takes to feather back to sharp. Everything above that boundary is + * blurred at full strength — iOS's `.soft` edge effect covers its whole region evenly and softens + * only at the edge, so a linear ramp from the very top leaves the title's depth barely touched. + */ + private val BlurTail = 24.dp + + /** Matched to the bottom bar's `ultraThin` material, so both edges soften by the same amount. */ + private val BlurRadius = 20.dp + + /** The region the fade covers, given the bar's measured height. */ + fun fadeHeight(barHeight: Dp): Dp = (barHeight - FadeEndInset).coerceAtLeast(0.dp) + + /** The region the blur covers at full strength, before the tail feathers it out. */ + fun blurHold(barHeight: Dp): Dp = barHeight + + /** + * Opaque behind the status bar, then a straight ramp to transparent — iOS's three stops. Alpha + * lands near a third behind the title, which is only legible because the blur is under it. + */ + fun fadeBrush(containerColor: Color, statusBars: Dp, fadeHeight: Dp): Brush { + val hold = when { + fadeHeight <= 0.dp -> 0f + else -> ((statusBars - OpaqueInsetTrim) / fadeHeight).coerceIn(0f, 1f) + } + return Brush.verticalGradient( + colorStops = arrayOf( + 0f to containerColor, + hold to containerColor, + 1f to Color.Transparent, + ) + ) + } + + /** + * Blurs the top [height] of whatever this is applied to, easing back to sharp over that + * distance — the counterpart of iOS's `topEdgeEffect.style = .soft`. + * + * `RenderEffect` arrived in API 31, so below that the fade is on its own. That is the same + * trade the platform makes elsewhere, and the fade alone still keeps an amount out of the status + * bar; it just stops short of hiding it under the title. + */ + @Composable + fun Modifier.softTopEdge(hold: Dp): Modifier { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return this + val density = LocalDensity.current + val holdPx = with(density) { hold.toPx() } + val tailPx = with(density) { BlurTail.toPx() } + val radiusPx = with(density) { BlurRadius.toPx() } + if (holdPx <= 0f || radiusPx <= 0f) return this + val effect = remember(holdPx, tailPx, radiusPx) { softTopEdgeEffect(holdPx, tailPx, radiusPx) } + return graphicsLayer { + renderEffect = effect + // The blur samples beyond the layer without this, dragging the bars' pixels inward. + clip = true + } + } + + /** + * A blurred copy of the layer, masked to the top of it, drawn over the sharp original. The mask is + * what shapes the blur: opaque for [holdPx], then falling to nothing across [tailPx], so the + * blurred copy shows through completely under the bar and not at all below the tail. + */ + @RequiresApi(Build.VERSION_CODES.S) + private fun softTopEdgeEffect( + holdPx: Float, + tailPx: Float, + radiusPx: Float, + ): androidx.compose.ui.graphics.RenderEffect { + val blurred = RenderEffect.createBlurEffect(radiusPx, radiusPx, Shader.TileMode.CLAMP) + val totalPx = holdPx + tailPx + val mask = RenderEffect.createShaderEffect( + LinearGradient( + 0f, 0f, 0f, totalPx, + intArrayOf(AndroidColor.BLACK, AndroidColor.BLACK, AndroidColor.TRANSPARENT), + floatArrayOf(0f, holdPx / totalPx, 1f), + Shader.TileMode.CLAMP, + ) + ) + val topOnly = + RenderEffect.createBlendModeEffect(blurred, mask, AndroidBlendMode.DST_IN) + val sharp = RenderEffect.createOffsetEffect(0f, 0f) + return RenderEffect + .createBlendModeEffect(sharp, topOnly, AndroidBlendMode.SRC_OVER) + .asComposeRenderEffect() + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 13cb23bc2a..561ae58212 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -115,7 +115,8 @@ class ChatIdentityScreenshotTest { ChatType.CONTACT_DM }, ), - chatActionHandler = {}, + onBarHeightChange = {}, + chatActionHandler = {}, dispatch = {}, ) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt index ec0306c903..30304bdff4 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionScreenshotTest.kt @@ -98,7 +98,8 @@ class ChatMessageActionScreenshotTest { chatType = ChatType.CONTACT_DM, selection = selection, ), - chatActionHandler = {}, + onBarHeightChange = {}, + chatActionHandler = {}, dispatch = {}, ) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt index d06f40c7f3..0dbd97cb83 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt @@ -59,7 +59,8 @@ class ChatSelectionBarTest { chatType = ChatType.CONTACT_DM, selection = cash, ), - chatActionHandler = {}, + onBarHeightChange = {}, + chatActionHandler = {}, dispatch = {}, ) } From d67f6ef509bc4165788510f33cba37af7451e235 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:31:41 -0400 Subject: [PATCH 4/6] fix(chat): carry the top edge's fade and blur through the title iOS's fade is spent just short of the bar's bottom edge, which leaves alpha near a third behind the title. That is enough against ordinary bubbles and not enough against a cash card, whose amount is large white text: it stayed legible over the title. Run the ramp 16dp past the bar instead, which puts roughly half alpha at the title, and take the blur from 20dp to 28dp so the digits stop resolving. The fade is drawn rather than laid out now. As a sibling it would have to be taller than the bar to reach past it, and the scaffold turns the top bar slot's height into the transcript's content padding, so a purely visual tail would have pushed every message down. --- .../internal/screens/components/ChatTopBar.kt | 16 ++---- .../screens/components/ChatTopEdge.kt | 56 +++++++++++-------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 1d761e4544..8e9c787501 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -53,6 +53,7 @@ import com.getcode.theme.extraLarge import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton +import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.topFade import com.getcode.ui.core.measured import com.getcode.ui.core.unboundedClickable import com.getcode.ui.utils.KeyboardController @@ -66,7 +67,6 @@ internal fun ChatTopBar( chatActionHandler: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, ) { - var titleHeight by remember { mutableStateOf(0.dp) } val bgColor = CodeTheme.colors.background val statusBars = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() // Held here rather than in the selection bar: KeyboardController.visible only starts tracking @@ -74,13 +74,6 @@ internal fun ChatTopBar( // the IME already up — a controller created there would read it as hidden. val keyboard = rememberKeyboardController() Box { - val fadeHeight = ChatTopEdge.fadeHeight(titleHeight) - Box( - modifier = Modifier - .fillMaxWidth() - .height(fadeHeight) - .background(ChatTopEdge.fadeBrush(bgColor, statusBars, fadeHeight)) - ) // A message action takes the bar over rather than stacking a second one over it, so the // conversation's own actions can't be reached while one is pending. The takeover holds // through the edit that a selection can lead to: dropping back to the title bar mid-edit @@ -91,10 +84,9 @@ internal fun ChatTopBar( else -> TopBarMode.Conversation } AnimatedContent( - modifier = Modifier.measured { - titleHeight = it.height - onBarHeightChange(it.height) - }, + modifier = Modifier + .topFade(bgColor, statusBars) + .measured { onBarHeightChange(it.height) }, targetState = mode, contentKey = { it::class }, transitionSpec = { fadeIn() togetherWith fadeOut() }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt index de574e9669..c43692d6fe 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt @@ -8,6 +8,8 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asComposeRenderEffect @@ -31,11 +33,12 @@ import android.graphics.Color as AndroidColor internal object ChatTopEdge { /** - * How far above the bar's bottom edge the fade finishes. iOS clears 8pt above a 44pt bar; the - * point is that the fade is spent by the time the title is passed, not that content is hidden - * all the way down. The blur carries the last of the transition. + * How far past the bar's bottom edge the fade runs. iOS finishes just short of that edge, which + * leaves alpha near a third behind the title — enough there, but not against a cash card's + * amount, which is large white text. Carrying the ramp past the bar puts roughly half alpha at + * the title instead, and the extra distance keeps the falloff from reading as a band. */ - private val FadeEndInset = 10.dp + private val FadeTail = 16.dp /** * How far short of the status bar's bottom edge the fade starts, so the strip behind the clock @@ -51,30 +54,39 @@ internal object ChatTopEdge { */ private val BlurTail = 24.dp - /** Matched to the bottom bar's `ultraThin` material, so both edges soften by the same amount. */ - private val BlurRadius = 20.dp - - /** The region the fade covers, given the bar's measured height. */ - fun fadeHeight(barHeight: Dp): Dp = (barHeight - FadeEndInset).coerceAtLeast(0.dp) + /** + * Deeper than the bottom bar's `ultraThin` material. The bottom bar sits over ordinary bubbles; + * this one has to hold up against an amount, so it needs the extra radius to stop the digits + * resolving through it. + */ + private val BlurRadius = 28.dp /** The region the blur covers at full strength, before the tail feathers it out. */ fun blurHold(barHeight: Dp): Dp = barHeight /** - * Opaque behind the status bar, then a straight ramp to transparent — iOS's three stops. Alpha - * lands near a third behind the title, which is only legible because the blur is under it. + * Draws the fade behind the bar: opaque to [containerColor] behind the status bar, then a + * straight ramp to transparent — iOS's three stops, run on past the bar's own bottom edge. + * + * Drawn rather than laid out because the ramp is longer than the bar. A sibling of that height + * would grow the top bar's slot, and the scaffold turns that slot's height into the transcript's + * content padding, so a purely visual tail would push every message down. */ - fun fadeBrush(containerColor: Color, statusBars: Dp, fadeHeight: Dp): Brush { - val hold = when { - fadeHeight <= 0.dp -> 0f - else -> ((statusBars - OpaqueInsetTrim) / fadeHeight).coerceIn(0f, 1f) - } - return Brush.verticalGradient( - colorStops = arrayOf( - 0f to containerColor, - hold to containerColor, - 1f to Color.Transparent, - ) + fun Modifier.topFade(containerColor: Color, statusBars: Dp): Modifier = drawBehind { + val height = size.height + FadeTail.toPx() + if (height <= 0f) return@drawBehind + val hold = ((statusBars.toPx() - OpaqueInsetTrim.toPx()) / height).coerceIn(0f, 1f) + drawRect( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0f to containerColor, + hold to containerColor, + 1f to Color.Transparent, + ), + startY = 0f, + endY = height, + ), + size = Size(size.width, height), ) } From c750e511c99a65788b64c5330d4a22d8c171a95e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:39:03 -0400 Subject: [PATCH 5/6] fix(chat): stop the top edge's blur reading as a glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blurred copy was composited straight over the untouched layer, which does not replace it: the transcript's background is transparent, and so is the spread of a blurred glyph, so the sharp original showed through its own blurred copy. White text on a dark ground came back as a crisp glyph wearing a halo. Mask both halves against complementary gradients instead, so the two cover every pixel once — blurred under the bar, sharp below the tail, a crossfade between. Scale the blurred copy's brightness to 0.6 while there. A blur spreads a pixel's light over its radius, so even without the doubling it hands back more lit area than it was given; taking the light out is a different correction from the fade, which mixes towards the background evenly rather than only where the blur lit up. --- .../screens/components/ChatTopEdge.kt | 80 +++++++++++++++---- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt index c43692d6fe..2a6dcf345e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens.components +import android.graphics.ColorMatrixColorFilter import android.graphics.LinearGradient import android.graphics.RenderEffect import android.graphics.Shader @@ -34,9 +35,8 @@ internal object ChatTopEdge { /** * How far past the bar's bottom edge the fade runs. iOS finishes just short of that edge, which - * leaves alpha near a third behind the title — enough there, but not against a cash card's - * amount, which is large white text. Carrying the ramp past the bar puts roughly half alpha at - * the title instead, and the extra distance keeps the falloff from reading as a band. + * leaves alpha near a third behind the title. The extra distance both raises alpha there and + * keeps the falloff from reading as a band across whichever bubble it lands on. */ private val FadeTail = 16.dp @@ -61,6 +61,17 @@ internal object ChatTopEdge { */ private val BlurRadius = 28.dp + /** + * How much of the blurred copy's brightness survives. + * + * A blur spreads a pixel's light over its radius, so white text on a dark ground comes back as a + * halo covering more area than the glyphs did — the wider the radius, the more it reads as a + * glow rather than as something out of focus. Scaling the blurred copy down before compositing + * takes the light back out, which is a different job from the fade: the fade mixes towards the + * background evenly, while this only touches what the blur lit up. + */ + private const val BlurBrightness = 0.6f + /** The region the blur covers at full strength, before the tail feathers it out. */ fun blurHold(barHeight: Dp): Dp = barHeight @@ -115,9 +126,13 @@ internal object ChatTopEdge { } /** - * A blurred copy of the layer, masked to the top of it, drawn over the sharp original. The mask is - * what shapes the blur: opaque for [holdPx], then falling to nothing across [tailPx], so the - * blurred copy shows through completely under the bar and not at all below the tail. + * The layer's top swapped for a blurred copy of itself: blurred where the mask is opaque, sharp + * where it is clear, crossfading between the two across the tail. + * + * Both halves have to be masked. Drawing the blurred copy over an untouched original instead + * leaves the original showing through it — the transcript's own background is transparent, and + * so is a blurred glyph's spread — which reads as a sharp glyph wearing a halo rather than as + * one out of focus. On white text over a dark ground that halo is a glow. */ @RequiresApi(Build.VERSION_CODES.S) private fun softTopEdgeEffect( @@ -125,21 +140,54 @@ internal object ChatTopEdge { tailPx: Float, radiusPx: Float, ): androidx.compose.ui.graphics.RenderEffect { - val blurred = RenderEffect.createBlurEffect(radiusPx, radiusPx, Shader.TileMode.CLAMP) val totalPx = holdPx + tailPx - val mask = RenderEffect.createShaderEffect( + val stops = floatArrayOf(0f, holdPx / totalPx, 1f) + + val blurred = RenderEffect.createColorFilterEffect( + ColorMatrixColorFilter( + floatArrayOf( + BlurBrightness, 0f, 0f, 0f, 0f, + 0f, BlurBrightness, 0f, 0f, 0f, + 0f, 0f, BlurBrightness, 0f, 0f, + 0f, 0f, 0f, 1f, 0f, + ) + ), + RenderEffect.createBlurEffect(radiusPx, radiusPx, Shader.TileMode.CLAMP), + ) + val blurredTop = RenderEffect.createBlendModeEffect( + blurred, + verticalMask(totalPx, stops, opaqueFirst = true), + AndroidBlendMode.DST_IN, + ) + val sharpRest = RenderEffect.createBlendModeEffect( + RenderEffect.createOffsetEffect(0f, 0f), + verticalMask(totalPx, stops, opaqueFirst = false), + AndroidBlendMode.DST_IN, + ) + return RenderEffect + .createBlendModeEffect(sharpRest, blurredTop, AndroidBlendMode.SRC_OVER) + .asComposeRenderEffect() + } + + /** + * A mask running the height of the effect: opaque for the hold and falling off across the tail, + * or the exact complement of that, so the two together cover every pixel once. + */ + @RequiresApi(Build.VERSION_CODES.S) + private fun verticalMask( + totalPx: Float, + stops: FloatArray, + opaqueFirst: Boolean, + ): RenderEffect { + val near = if (opaqueFirst) AndroidColor.BLACK else AndroidColor.TRANSPARENT + val far = if (opaqueFirst) AndroidColor.TRANSPARENT else AndroidColor.BLACK + return RenderEffect.createShaderEffect( LinearGradient( 0f, 0f, 0f, totalPx, - intArrayOf(AndroidColor.BLACK, AndroidColor.BLACK, AndroidColor.TRANSPARENT), - floatArrayOf(0f, holdPx / totalPx, 1f), + intArrayOf(near, near, far), + stops, Shader.TileMode.CLAMP, ) ) - val topOnly = - RenderEffect.createBlendModeEffect(blurred, mask, AndroidBlendMode.DST_IN) - val sharp = RenderEffect.createOffsetEffect(0f, 0f) - return RenderEffect - .createBlendModeEffect(sharp, topOnly, AndroidBlendMode.SRC_OVER) - .asComposeRenderEffect() } } From c9e81216229bd6f21ee88f152f65ebdcf4a0e92c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:41:48 -0400 Subject: [PATCH 6/6] fix(chat): ease the top edge's falloff instead of ramping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ramps were straight lines, and a straight line turns a corner at each end — full strength one pixel, already dropping the next. Those corners are what the eye reads as the edges of a band, however long the ramp between them is. Sample a smoothstep instead, so each ramp leaves and arrives flat. Lengthen them too: the blur's falloff goes from 24dp to 64dp and the fade's tail from 16dp to 36dp. This is the distance over which a bubble goes from unreadable to readable and the eye follows it the whole way, so a short one delivers that change of state as an event rather than as a transition. Start the blur easing 16dp above the bar's bottom edge rather than at it. Held to the edge, the falloff begins exactly where a bubble emerges from behind the bar, and the two coincide into a single hard boundary. Combine the two masked copies with PLUS rather than SRC_OVER. They are exact complements, so adding them weights each pixel between sharp and blurred once; compositing one over the other put the lower through its own mask a second time, which dipped the middle of the transition darker. --- .../screens/components/ChatTopEdge.kt | 79 +++++++++++++------ 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt index 2a6dcf345e..9eeac8d49d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopEdge.kt @@ -38,7 +38,7 @@ internal object ChatTopEdge { * leaves alpha near a third behind the title. The extra distance both raises alpha there and * keeps the falloff from reading as a band across whichever bubble it lands on. */ - private val FadeTail = 16.dp + private val FadeTail = 36.dp /** * How far short of the status bar's bottom edge the fade starts, so the strip behind the clock @@ -48,11 +48,22 @@ internal object ChatTopEdge { private val OpaqueInsetTrim = 12.dp /** - * How far past the bar the blur takes to feather back to sharp. Everything above that boundary is - * blurred at full strength — iOS's `.soft` edge effect covers its whole region evenly and softens - * only at the edge, so a linear ramp from the very top leaves the title's depth barely touched. + * How far the blur takes to feather back to sharp. Everything above that falloff is blurred at + * full strength — iOS's `.soft` edge effect covers its whole region evenly and softens only at + * the edge, so a ramp starting at the very top leaves the title's depth barely touched. + * + * Long, because this is the distance over which a bubble goes from unreadable to readable and + * the eye follows it the whole way. Over a short one the same change of state arrives as an + * event rather than as a transition. + */ + private val BlurTail = 64.dp + + /** + * How far above the bar's bottom edge the blur starts easing off. Holding full strength right to + * that edge means the falloff begins exactly where a bubble emerges, so the two coincide and + * read as one hard boundary; starting earlier separates them. */ - private val BlurTail = 24.dp + private val BlurHoldTrim = 16.dp /** * Deeper than the bottom bar's `ultraThin` material. The bottom bar sits over ordinary bubbles; @@ -73,7 +84,19 @@ internal object ChatTopEdge { private const val BlurBrightness = 0.6f /** The region the blur covers at full strength, before the tail feathers it out. */ - fun blurHold(barHeight: Dp): Dp = barHeight + fun blurHold(barHeight: Dp): Dp = (barHeight - BlurHoldTrim).coerceAtLeast(0.dp) + + /** + * Where a ramp has got to, a fraction of the way along it. + * + * Smoothstep rather than a straight line. A linear ramp turns a corner at each end — full + * strength one pixel, already dropping the next — and those corners are what the eye picks out + * as the edges of a band, however long the ramp between them is. This leaves and arrives flat. + */ + private fun eased(t: Float): Float = t * t * (3f - 2f * t) + + /** Fractions along a ramp at which to sample [eased], enough for the steps not to show. */ + private val RampSamples = floatArrayOf(0f, 0.15f, 0.3f, 0.45f, 0.6f, 0.75f, 0.9f, 1f) /** * Draws the fade behind the bar: opaque to [containerColor] behind the status bar, then a @@ -87,16 +110,16 @@ internal object ChatTopEdge { val height = size.height + FadeTail.toPx() if (height <= 0f) return@drawBehind val hold = ((statusBars.toPx() - OpaqueInsetTrim.toPx()) / height).coerceIn(0f, 1f) + val stops = Array(RampSamples.size + 1) { index -> + if (index == 0) { + 0f to containerColor + } else { + val t = RampSamples[index - 1] + (hold + t * (1f - hold)) to containerColor.copy(alpha = 1f - eased(t)) + } + } drawRect( - brush = Brush.verticalGradient( - colorStops = arrayOf( - 0f to containerColor, - hold to containerColor, - 1f to Color.Transparent, - ), - startY = 0f, - endY = height, - ), + brush = Brush.verticalGradient(colorStops = stops, startY = 0f, endY = height), size = Size(size.width, height), ) } @@ -141,7 +164,10 @@ internal object ChatTopEdge { radiusPx: Float, ): androidx.compose.ui.graphics.RenderEffect { val totalPx = holdPx + tailPx - val stops = floatArrayOf(0f, holdPx / totalPx, 1f) + val hold = holdPx / totalPx + val stops = FloatArray(RampSamples.size + 1) { index -> + if (index == 0) 0f else hold + RampSamples[index - 1] * (1f - hold) + } val blurred = RenderEffect.createColorFilterEffect( ColorMatrixColorFilter( @@ -164,13 +190,16 @@ internal object ChatTopEdge { verticalMask(totalPx, stops, opaqueFirst = false), AndroidBlendMode.DST_IN, ) + // PLUS, not SRC_OVER: the two masks are complements, so adding them weights each pixel + // between the copies exactly once. Compositing one over the other would put the lower + // through its own mask a second time, dipping the transition darker in the middle. return RenderEffect - .createBlendModeEffect(sharpRest, blurredTop, AndroidBlendMode.SRC_OVER) + .createBlendModeEffect(sharpRest, blurredTop, AndroidBlendMode.PLUS) .asComposeRenderEffect() } /** - * A mask running the height of the effect: opaque for the hold and falling off across the tail, + * A mask running the height of the effect: opaque for the hold and easing off across the tail, * or the exact complement of that, so the two together cover every pixel once. */ @RequiresApi(Build.VERSION_CODES.S) @@ -179,15 +208,13 @@ internal object ChatTopEdge { stops: FloatArray, opaqueFirst: Boolean, ): RenderEffect { - val near = if (opaqueFirst) AndroidColor.BLACK else AndroidColor.TRANSPARENT - val far = if (opaqueFirst) AndroidColor.TRANSPARENT else AndroidColor.BLACK + val colors = IntArray(stops.size) { index -> + val covered = if (index == 0) 0f else eased(RampSamples[index - 1]) + val alpha = if (opaqueFirst) 1f - covered else covered + AndroidColor.argb((alpha * 255f).toInt(), 0, 0, 0) + } return RenderEffect.createShaderEffect( - LinearGradient( - 0f, 0f, 0f, totalPx, - intArrayOf(near, near, far), - stops, - Shader.TileMode.CLAMP, - ) + LinearGradient(0f, 0f, 0f, totalPx, colors, stops, Shader.TileMode.CLAMP) ) } }