From 87697b4a147a2441d5a589a9d6140d4118d44a2e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 16:46:50 -0400 Subject: [PATCH] feat(tipping): fade the Chats list under its title bar The Chats list was cut off at the title bar instead of passing under it, unlike the chat screen's message list. M2's Scaffold shortens its body by the bar heights and places it between them, so CodeScaffold could not express that placement and the chat screen carried a private ChatInputScaffold instead. CodeScaffold now takes a ScaffoldBarPlacement: Overlay leaves the Scaffold's own bar slots empty and subcomposes the bars over a full-size content, handing their measured heights back as the content's padding. Measuring both in one pass keeps the content correctly padded on the first frame; onSizeChanged gives it zero on frame one and snaps on frame two. Inset stays the default, so no other caller moves. The Chats tab uses Overlay with a background-to-transparent scrim behind AppBarWithTitle, drawn with drawBehind so the scrim's tail past the bar's bottom edge stays out of the height the list pads by. The messenger screen moves onto the same scaffold and its private one is deleted. --- .../internal/screens/MessengerScreen.kt | 53 ++---------- .../com/flipcash/app/tipping/TipsScreen.kt | 64 ++++++++++---- .../app/tipping/ChatsTabLayoutTest.kt | 84 +++++++++++++++++++ .../com/getcode/ui/theme/CodeScaffold.kt | 78 ++++++++++++++++- 4 files changed, 216 insertions(+), 63 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.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 5b708fad50..c56dc3e58b 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 @@ -1,12 +1,10 @@ package com.flipcash.app.messenger.internal.screens -import androidx.compose.foundation.layout.PaddingValues 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.ui.Modifier -import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.testTag import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems @@ -18,6 +16,8 @@ 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 import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.theme.ScaffoldBarPlacement import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -75,7 +75,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { Unit } - ChatInputScaffold( + CodeScaffold( + // The input bar rides the keyboard; the message list is inset by it either way. + modifier = Modifier.imePadding(), + // 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) }, bottomBar = { UserControlBottomBar( @@ -100,45 +105,3 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ) } } - -@Composable -private fun ChatInputScaffold( - topBar: @Composable () -> Unit = {}, - bottomBar: @Composable () -> Unit = {}, - content: @Composable (PaddingValues) -> Unit, -) { - // Overlay scaffold: the content fills the whole area (drawn behind the blurred bars) and is - // inset by the bar heights. SubcomposeLayout measures the bars BEFORE the content in the same - // layout pass, so the content receives correct padding on the very first frame. The previous - // onSizeChanged approach fed 0 padding on frame 1 and snapped to the real heights on frame 2, - // which made the message list visibly jump on every open and every pop-back. - SubcomposeLayout( - modifier = Modifier - .imePadding(), - ) { constraints -> - val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - val topPlaceables = subcompose(ChatScaffoldSlot.Top, topBar).map { it.measure(looseConstraints) } - val bottomPlaceables = subcompose(ChatScaffoldSlot.Bottom, bottomBar).map { it.measure(looseConstraints) } - val topHeight = topPlaceables.maxOfOrNull { it.height } ?: 0 - val bottomHeight = bottomPlaceables.maxOfOrNull { it.height } ?: 0 - - val padding = PaddingValues( - top = topHeight.toDp(), - bottom = bottomHeight.toDp(), - ) - - val contentPlaceables = subcompose(ChatScaffoldSlot.Content) { content(padding) } - .map { it.measure(constraints) } - - layout(constraints.maxWidth, constraints.maxHeight) { - contentPlaceables.forEach { it.place(0, 0) } - topPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) } - bottomPlaceables.forEach { - it.place((constraints.maxWidth - it.width) / 2, constraints.maxHeight - it.height) - } - } - } -} - -private enum class ChatScaffoldSlot { Top, Bottom, Content } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt index c03337b025..1285641bbe 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,16 +16,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier @@ -42,6 +46,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.theme.ScaffoldBarPlacement /** * The tip DM conversation list — always a step in the tipping [TippingFlowScreen] flow, so it shares @@ -55,9 +60,31 @@ fun TipsScreen() { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current + val chats = state.tipChats + val listState = rememberLazyListState() + CodeScaffold( + // The list runs the full height and passes under the title bar, which fades it out against + // the background at its own edge — the same treatment the chat screen gives its message + // list, rather than cutting the list off at the bar. + barPlacement = ScaffoldBarPlacement.Overlay, topBar = { + val backgroundColor = CodeTheme.colors.background AppBarWithTitle( + // Drawn behind the bar rather than as a box sized to it, so the scrim's reach past + // the bar's bottom edge stays out of the bar's own measurement — which is what the + // list is padded by. + modifier = Modifier.drawBehind { + val scrimHeight = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 0f, + endY = scrimHeight, + ), + size = size.copy(height = scrimHeight), + ) + }, title = stringResource(R.string.title_chats), // Centred rather than flush-start: an empty leading slot reserves no width, so a // Start title sits at the inset and reads as off-centre against the Add button. @@ -69,25 +96,28 @@ fun TipsScreen() { AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } }, ) - } - ) { padding -> - val chats = state.tipChats - val listState = rememberLazyListState() + }, + ) { barPadding -> LazyColumn( modifier = Modifier .fillMaxSize() // Scroll anchor for UI tests: `send_contact_row` addresses a single row, this // addresses the scrollable list itself. .testTag("chat_list") - .padding(padding) - // After the padding so the fade lands on the list viewport — flush under the app - // bar — rather than on the bar's own space. Rows dissolve into the background at - // whichever edge is still scrollable instead of being cut off. - .verticalScrollStateGradient(scrollState = listState), + // End edge only — the start edge is the bar's scrim now, and a second fade there + // would darken rows twice over as they pass under the title. + .verticalScrollStateGradient(scrollState = listState, showAtStart = false), state = listState, - // Clears the hoisted tab bar: keeps the last row reachable and centers the empty state - // in the space the bar leaves visible. - contentPadding = LocalTabBarPadding.current, + contentPadding = PaddingValues( + // The bar's height as content padding rather than as a layout inset: the viewport + // runs the full height and rows scroll under the bar, but at rest the first row + // still sits clear of it. + top = barPadding.calculateTopPadding(), + // Clears the hoisted tab bar: keeps the last row reachable. Both paddings are + // measured out of `fillParentMaxSize`, so the empty state stays centered in the + // space the two bars leave visible. + bottom = LocalTabBarPadding.current.calculateBottomPadding(), + ), ) { // Once the feed has loaded and there's nothing to show, the list is replaced by a // centered prompt. @@ -102,6 +132,12 @@ fun TipsScreen() { } } +/** + * How far past the bar's bottom edge the scrim reaches before it is fully transparent. Rows begin + * dissolving this far below the title rather than only once they meet it. + */ +private val ScrimTail = 48.dp + /** * The "Chats" tab empty state (node 9340:2746) — bubble mark, title and prompt, centered in the * space the caller gives it (the list viewport). diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt new file mode 100644 index 0000000000..738ee44326 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.app.tipping + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.theme.ScaffoldBarPlacement +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The Chats tab draws its list under the title bar and pads it by the bar's height, so rows scroll + * beneath the title and fade into it rather than being cut at its edge. That is + * [ScaffoldBarPlacement.Overlay], and both halves of it are checked here: the list's viewport spans + * the whole screen (it is not inset by either bar), and the first row still rests below the top bar + * — on the first frame, with no measurement pass to jump from. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ChatsTabLayoutTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun listFillsScreenAndFirstRowClearsTheBar() { + composeRule.setContent { + FlipcashPreview(showBackground = true) { + CodeScaffold( + barPlacement = ScaffoldBarPlacement.Overlay, + topBar = { Box(Modifier.fillMaxWidth().height(56.dp).testTag("bar")) }, + bottomBar = { Box(Modifier.fillMaxWidth().height(80.dp).testTag("bottom")) }, + ) { barPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("list"), + contentPadding = PaddingValues(top = barPadding.calculateTopPadding()), + ) { + items((0 until 40).toList()) { + Box(Modifier.fillMaxWidth().height(40.dp).testTag("row_$it")) + } + } + } + } + } + composeRule.waitForIdle() + + assertEquals("list starts at the top of the screen", 0f, top("list"), 0.5f) + assertEquals( + "list runs past the bottom bar", + top("bottom") + height("bottom"), + top("list") + height("list"), + 0.5f, + ) + assertEquals( + "first row rests below the top bar", + top("bar") + height("bar"), + top("row_0"), + 0.5f, + ) + } + + private fun top(tag: String): Float = + composeRule.onNode(hasTestTag(tag)).fetchSemanticsNode().positionInRoot.y + + private fun height(tag: String): Float = + composeRule.onNode(hasTestTag(tag)).fetchSemanticsNode().size.height.toFloat() +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt index bec86a0840..dcc228544f 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt @@ -13,10 +13,25 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.unit.Dp import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge +/** Where a scaffold's bars sit relative to its content. */ +enum class ScaffoldBarPlacement { + /** The bars take their own space and the content is laid out between them. */ + Inset, + + /** + * The bars are drawn over the content, which fills the whole area and is handed their heights + * as padding to inset itself by. Applied as content padding on a scrolling list, rows run + * underneath the bars — fading into whatever fade the bar draws over them — instead of being + * cut off at the bar's edge, while still coming to rest clear of it. + */ + Overlay, +} + @Composable fun CodeScaffold( modifier: Modifier = Modifier, @@ -40,13 +55,18 @@ fun CodeScaffold( drawerScrimColor: Color = CodeTheme.colors.brandLight, backgroundColor: Color = CodeTheme.colors.background, contentColor: Color = CodeTheme.colors.onBackground, + barPlacement: ScaffoldBarPlacement = ScaffoldBarPlacement.Inset, content: @Composable (PaddingValues) -> Unit ) { + val isOverlay = barPlacement == ScaffoldBarPlacement.Overlay Scaffold( modifier = modifier, scaffoldState = scaffoldState, - topBar = topBar, - bottomBar = bottomBar, + // Overlay hands the bars to [OverlayBars] below instead: [Scaffold]'s own slots shorten + // the body by the bars' heights and place it between them, which is the placement Overlay + // exists to avoid. + topBar = if (isOverlay) ({}) else topBar, + bottomBar = if (isOverlay) ({}) else bottomBar, snackbarHost = snackbarHost, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, @@ -60,6 +80,56 @@ fun CodeScaffold( drawerScrimColor = drawerScrimColor, backgroundColor = backgroundColor, contentColor = contentColor, - content = content + content = { padding -> + if (isOverlay) { + OverlayBars(topBar = topBar, bottomBar = bottomBar, content = content) + } else { + content(padding) + } + } ) -} \ No newline at end of file +} + +/** + * Draws [topBar] and [bottomBar] over a full-size [content], which is handed their heights as + * padding. + * + * The bars are subcomposed and measured BEFORE the content in the same layout pass, so the content + * receives correct padding on the very first frame. Measuring them with `onSizeChanged` instead fed + * 0 padding on frame 1 and snapped to the real heights on frame 2, which made the chat's message + * list visibly jump on every open and every pop-back. + */ +@Composable +private fun OverlayBars( + topBar: @Composable () -> Unit, + bottomBar: @Composable () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + SubcomposeLayout { constraints -> + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + val topPlaceables = subcompose(OverlaySlot.Top, topBar).map { it.measure(looseConstraints) } + val bottomPlaceables = + subcompose(OverlaySlot.Bottom, bottomBar).map { it.measure(looseConstraints) } + val topHeight = topPlaceables.maxOfOrNull { it.height } ?: 0 + val bottomHeight = bottomPlaceables.maxOfOrNull { it.height } ?: 0 + + val padding = PaddingValues( + top = topHeight.toDp(), + bottom = bottomHeight.toDp(), + ) + + val contentPlaceables = subcompose(OverlaySlot.Content) { content(padding) } + .map { it.measure(constraints) } + + layout(constraints.maxWidth, constraints.maxHeight) { + contentPlaceables.forEach { it.place(0, 0) } + topPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) } + bottomPlaceables.forEach { + it.place((constraints.maxWidth - it.width) / 2, constraints.maxHeight - it.height) + } + } + } +} + +private enum class OverlaySlot { Top, Bottom, Content } \ No newline at end of file