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