diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 12c72be038..e09642c9fa 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -281,6 +281,11 @@ internal class ChatViewModel @Inject constructor( * its actions disabled. */ val selection: ChatListItem.ContentBubble? = null, + /** + * Whether [selection] was made by a double tap, which presents only the quick reaction strip: + * the lift and backdrop without the selection bar. Only meaningful while [selection] is set. + */ + val reactionStripOnly: Boolean = false, /** The message the composer is editing, or `null` when it is composing a new one. */ val editing: EditingMessage? = null, /** @@ -603,6 +608,12 @@ internal class ChatViewModel @Inject constructor( /** Selects [bubble], or leaves selection mode if it is already the selected one. */ data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event + + /** + * Selects [bubble] for the quick reaction strip alone, as a double tap does; nothing when it + * can't take a reaction once narrowed to what is open now. + */ + data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : Event data object ClearMessageSelection : Event // The message actions carry what they act on rather than reading it back off the selection: @@ -2657,19 +2668,25 @@ internal class ChatViewModel @Inject constructor( // By message rather than by row: a message split around its card is one // selection whichever of its rows was pressed. val alreadySelected = state.selection?.messageKey == event.bubble.messageKey - // The transcript resolved this bubble when it was mapped, which may have been - // well inside a window that has since closed. Narrow it again here so the bar - // offers what is open now rather than what was open when the row was built. - // The same goes for membership: a viewer who has left since keeps only what a - // reader outside the group may do. - val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble -> - val open = bubble.capabilities - .withinWindows(bubble.timestamp, state.messagePolicy) - bubble.copy( - capabilities = if (state.isOutsideGroup) open.readOnly() else open, - ) + // Narrowed again so the bar offers what is open now, not when the row was built. + val selected = event.bubble.takeUnless { alreadySelected }?.narrowedFor(state) + state.copy( + selection = selected, + reactionStripOnly = false, + confirmingDelete = false, + ).withQuickReactionStrip() + } + is Event.PresentReactionStrip -> { state -> + val selected = event.bubble.narrowedFor(state).takeIf { it.canReact } + if (selected == null) { + state + } else { + state.copy( + selection = selected, + reactionStripOnly = true, + confirmingDelete = false, + ).withQuickReactionStrip() } - state.copy(selection = selected, confirmingDelete = false).withQuickReactionStrip() } Event.ClearMessageSelection -> { state -> state.copy(selection = null, confirmingDelete = false).withQuickReactionStrip() @@ -2753,6 +2770,17 @@ internal class ChatViewModel @Inject constructor( } } +/** + * This bubble with its capabilities narrowed to what is open now. The transcript resolved it when it + * was mapped, which may have been well inside a window that has since closed, so a selection offers + * what is open at the moment it is made. The same goes for membership: a viewer who has left since + * keeps only what a reader outside the group may do. + */ +private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State): ChatListItem.ContentBubble { + val open = capabilities.withinWindows(timestamp, state.messagePolicy) + return copy(capabilities = if (state.isOutsideGroup) open.readOnly() else open) +} + /** * [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing * is selected, while the selected message can't be reacted to, or before the inputs have loaded. 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 756d7cccd3..a567961c54 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,6 +1,10 @@ package com.flipcash.app.messenger.internal.screens import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable @@ -10,6 +14,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag @@ -154,6 +162,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble)) } + is ChatAction.PresentReactionStrip -> { + viewModel.dispatchEvent(ChatViewModel.Event.PresentReactionStrip(action.bubble)) + } + ChatAction.ClearSelection -> { viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) } @@ -273,12 +285,43 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ) }, bottomBar = { - UserControlBottomBar( - state = state, - hazeState = hazeState, - onAction = chatActionHandler, - dispatch = viewModel::dispatchEvent, + // Behind the backdrop with the transcript while a message is raised, dimmed and blurred + // as its rows are, and a tap on it dismisses the selection as a tap on them does. An + // edit raises a message too, but it is the composer doing the editing, so it stays sharp. + val behindBackdrop = state.selection != null && state.editing == null + val dimAlpha by animateFloatAsState( + targetValue = if (behindBackdrop) 0.4f else 1f, + label = "composerDim", + ) + val dimBlur by animateDpAsState( + targetValue = if (behindBackdrop) 8.dp else 0.dp, + label = "composerBlur", ) + Box { + Box( + modifier = Modifier + .blur(dimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { alpha = dimAlpha }, + ) { + UserControlBottomBar( + state = state, + hazeState = hazeState, + onAction = chatActionHandler, + dispatch = viewModel::dispatchEvent, + ) + } + if (behindBackdrop) { + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(Unit) { + detectTapGestures { + viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) + } + }, + ) + } + } }, ) { overlapPadding -> // The transcript and the info card go behind a blur together, because they are one surface 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 e892c621e3..f3ccfe8928 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 @@ -89,7 +89,8 @@ internal fun ChatTopBar( // would offer the profile and leave back as the only way out. val mode: TopBarMode = when { state.editing != null -> TopBarMode.Editing - state.selection != null -> TopBarMode.Selecting(state.selection) + // A double tap presents the strip alone, over the ordinary title bar. + state.selection != null && !state.reactionStripOnly -> TopBarMode.Selecting(state.selection) else -> TopBarMode.Conversation } AnimatedContent( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 34a1796938..0527d2def6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -40,6 +42,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.graphicsLayer import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -149,16 +154,38 @@ internal fun MessageList( // The backdrop, read once for everything it covers: the rows, the bubbles' own targets, // and the contact card at the start of history all stop taking taps together. val selecting = state.selection != null || state.editing != null + // The items past the oldest message (its date, the info or invite card) are not rows, so + // they take the rows' backdrop dim and blur here; nothing among them can be the focus. + val trailingDimAlpha by animateFloatAsState( + targetValue = if (selecting) 0.4f else 1f, + label = "trailingDim", + ) + val trailingDimBlur by animateDpAsState( + targetValue = if (selecting) 8.dp else 0.dp, + label = "trailingBlur", + ) + val behindBackdrop = Modifier + .blur(trailingDimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { alpha = trailingDimAlpha } // A message can be long-pressed while it is running under the top bar, which the fade // there makes easy to do — and the bar then swaps to the actions for it, so the row the // backdrop leaves sharp sits behind the buttons acting on it. Bring it down level with the // bar's lower edge before that happens. Selecting and then editing is one focus, not two, // so the key doesn't change across that step and the row isn't scrolled twice. + // + // A message that takes reactions comes down further, far enough for the quick strip to sit + // above it: the strip is always above, and there's always room to make, since the info card + // at the start of history sits over the oldest message. val focusedMessageId = state.editing?.messageId ?: state.selection?.messageId + val stripRoom = with(LocalDensity.current) { + (QuickReactionStripPlacement.StripHeight + QuickReactionStripPlacement.Gap + STRIP_BAR_GAP) + .roundToPx() + } LaunchedEffect(focusedMessageId) { if (focusedMessageId == null) return@LaunchedEffect - val buried = -listState.layoutInfo.headroomAbove(messages, focusedMessageId) + val room = if (state.editing == null && state.selection?.canReact == true) stripRoom else 0 + val buried = room - listState.layoutInfo.headroomAbove(messages, focusedMessageId) if (buried > 0) listState.animateScrollBy(buried.toFloat()) } @@ -430,7 +457,9 @@ internal fun MessageList( // rather than fetched. if (messages.itemCount == 0 && state.obscuresTranscript) { item(key = "gated-transcript-placeholder") { - GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) + GatedTranscriptPlaceholder( + modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), + ) } } @@ -449,7 +478,9 @@ internal fun MessageList( if (oldestTimestamp != null) { item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { Box( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), + modifier = Modifier + .padding(bottom = CodeTheme.dimens.grid.x2) + .then(behindBackdrop), ) { DateSeparatorRow(oldestTimestamp) } @@ -473,7 +504,7 @@ internal fun MessageList( ) } BoxWithConstraints( - modifier = Modifier.fillParentMaxWidth(), + modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), contentAlignment = Alignment.Center, ) { // The width a link card gets in a DM transcript, so this reads as the @@ -498,7 +529,8 @@ internal fun MessageList( item { Box( modifier = Modifier - .fillParentMaxWidth(), + .fillParentMaxWidth() + .then(behindBackdrop), contentAlignment = Alignment.Center, ) { ChatInfoCard( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 78d731440d..9753ae75d5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -163,6 +163,16 @@ internal fun MessageRow( onAction(ChatAction.ToggleSelection(target)) } } + // A double tap presents the strip alone, as iMessage's tapback does. Hoisted like select: a + // bubble with a tap target of its own -- a cash bubble, a link card, a quote -- reports it too, + // and holds its single tap for the double-tap timeout to tell the two apart. A button inside a + // card is not given it, so it stays instant. + val presentStrip = bubble?.takeIf { it.canReact }?.let { target -> + { + vibrator.tick() + onAction(ChatAction.PresentReactionStrip(target)) + } + } val dimAlpha by animateFloatAsState( targetValue = if (focused) 1f else 0.4f, @@ -241,6 +251,10 @@ internal fun MessageRow( interactionSource = interactionSource, indication = null, onLongClick = select, + onDoubleClick = presentStrip, + // select plays the tick; the platform's own long-press haptic on top of + // it is felt as a second buzz. + hapticFeedbackEnabled = false, // Only reachable with the backdrop down, so the tap has nothing to // dismiss but the keyboard. onClick = { keyboard.hide() }, @@ -357,6 +371,7 @@ internal fun MessageRow( // so the row's long-press never reaches it. Handing it the // same gesture is what makes a cash bubble selectable. onLongClick = select, + onDoubleClick = presentStrip, position = bubblePositionOf( index, item, @@ -561,7 +576,8 @@ private fun senderNameInset(showsGutter: Boolean): Dp = CodeTheme.dimens.grid.x1 } -private val STRIP_BAR_GAP = 8.dp +/** Between the top bar and the highest the quick strip goes. */ +internal val STRIP_BAR_GAP = 8.dp private val AFFORDANCE_SIZE = 32.dp private val AFFORDANCE_INSET = 20.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt index 7a84afc789..e1ab6feaf3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -7,6 +7,7 @@ import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.SelfReaction import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Instant @@ -114,4 +115,39 @@ class ChatReactionsReducerTest { assertEquals(selected, state) } + + @Test + fun `a double tap selects the bubble for the strip alone`() { + val state = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.PresentReactionStrip(bubble(1)), + ) + + assertEquals(1L, state.selection?.messageId) + assertTrue(state.reactionStripOnly) + assertTrue(state.quickReactionStrip.isNotEmpty()) + } + + @Test + fun `a double tap on a message that can't be reacted to does nothing`() { + val before = ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs) + + val state = reduce(before, ChatViewModel.Event.PresentReactionStrip(bubble(1, canReact = false))) + + assertEquals(before, state) + } + + @Test + fun `a long-press after a double tap brings the selection bar back`() { + val stripOnly = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.PresentReactionStrip(bubble(1)), + ) + val cleared = reduce(stripOnly, ChatViewModel.Event.ClearMessageSelection) + + val selected = reduce(cleared, ChatViewModel.Event.ToggleMessageSelection(bubble(1))) + + assertEquals(1L, selected.selection?.messageId) + assertFalse(selected.reactionStripOnly) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index a461f7dc55..c54da580f9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -68,6 +68,12 @@ sealed interface ChatAction { */ data class ToggleSelection(val bubble: ChatListItem.ContentBubble) : ChatAction + /** + * Presents only the quick reaction strip over [bubble], as a double tap does: the lift and + * backdrop a long-press gives, with no selection bar or actions. Dismissed like a selection. + */ + data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : ChatAction + /** Leaves selection mode without acting on anything. */ data object ClearSelection : ChatAction diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index 9d114931db..6f6c5c1bce 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -47,6 +47,7 @@ fun ChatQuotePanel( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val accent = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: accent @@ -64,7 +65,10 @@ fun ChatQuotePanel( .addIf(onClick != null || onLongClick != null) { Modifier.combinedClickable( onLongClick = onLongClick, + onDoubleClick = onDoubleClick, onClick = { onClick?.invoke() }, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) } .height(IntrinsicSize.Min), diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 4a36cfacff..734cc3a1ac 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -100,6 +100,7 @@ internal fun LinkCardView( onClick: ((LinkCard) -> Unit)?, modifier: Modifier = Modifier, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val live = rememberResolvedCard(card) @@ -111,6 +112,7 @@ internal fun LinkCardView( modifier = modifier.semantics { linkCardShape = shape }, shape = shape, onLongClick = onLongClick, + onDoubleClick = onDoubleClick, ) return } @@ -122,9 +124,12 @@ internal fun LinkCardView( modifier = modifier .fillMaxWidth() .semantics { linkCardShape = shape } - .addIf(onLongClick != null) { - Modifier.pointerInput(onLongClick) { - detectTapGestures(onLongPress = { onLongClick?.invoke() }) + .addIf(onLongClick != null || onDoubleClick != null) { + Modifier.pointerInput(onLongClick, onDoubleClick) { + detectTapGestures( + onLongPress = { onLongClick?.invoke() }, + onDoubleTap = onDoubleClick?.let { double -> { double() } }, + ) } }, ) { @@ -155,7 +160,10 @@ internal fun LinkCardView( .clip(shape) .combinedClickable( onLongClick = onLongClick, + onDoubleClick = onDoubleClick, onClick = { onClick?.invoke(live) }, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) }, ) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index d0ab027db5..13176d171a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -126,6 +126,7 @@ fun ContentBubble( modifier: Modifier = Modifier, interactive: Boolean = true, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, ) { val actionHandler = LocalChatActionHandler.current @@ -163,6 +164,7 @@ fun ContentBubble( onQuoteClick = onQuoteClick, onQuoteLongClick = onLongClick?.takeIf { interactive }, onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, interactive = interactive, attention = attention, ) @@ -219,6 +221,7 @@ fun ContentBubble( // Gated with the tap, and for the same reason: behind the backdrop the bar is // already acting on a message, and the row drops its own gestures there too. onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, attention = attention, ) @@ -237,6 +240,7 @@ fun ContentBubble( // should dismiss the backdrop, not jump the transcript out from under it. onQuoteClick = onQuoteClick, onQuoteLongClick = onLongClick?.takeIf { interactive }, + onQuoteDoubleClick = onDoubleClick?.takeIf { interactive }, jumbo = jumbo, attention = attention, ) @@ -354,6 +358,7 @@ private fun TextBubble( quote: ChatQuote? = null, onQuoteClick: (() -> Unit)? = null, onQuoteLongClick: (() -> Unit)? = null, + onQuoteDoubleClick: (() -> Unit)? = null, jumbo: Boolean = false, attention: () -> Float = { 0f }, ) { @@ -468,6 +473,7 @@ private fun TextBubble( quote = quote, onClick = onQuoteClick, onLongClick = onQuoteLongClick, + onDoubleClick = onQuoteDoubleClick, // Tagged because the citation repeats the quoted message's own text, so a // UI test matching on that text cannot tell the two apart. modifier = Modifier.testTag(REPLY_QUOTE_TAG), @@ -567,7 +573,9 @@ private fun BareLinkCard( quote: ChatQuote? = null, onQuoteClick: (() -> Unit)? = null, onQuoteLongClick: (() -> Unit)? = null, + onQuoteDoubleClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, interactive: Boolean = true, attention: () -> Float = { 0f }, ) { @@ -600,6 +608,7 @@ private fun BareLinkCard( quote = quote, onClick = onQuoteClick, onLongClick = onQuoteLongClick, + onDoubleClick = onDoubleClick, modifier = Modifier.testTag(REPLY_QUOTE_TAG), ) } @@ -612,6 +621,7 @@ private fun BareLinkCard( // The card takes the press for its own tap, so it has to hand the transcript's // selection gesture back or a long press on it would select nothing. onLongClick = onLongClick, + onDoubleClick = onDoubleClick, ) } } @@ -662,6 +672,7 @@ private fun CashBubble( action: MessageContent.Cash.Action = MessageContent.Cash.Action.SENT, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, modifier: Modifier = Modifier, attention: () -> Float = { 0f }, ) { @@ -672,6 +683,7 @@ private fun CashBubble( maxWidth = maxWidth, onClick = onClick, onLongClick = onLongClick, + onDoubleClick = onDoubleClick, modifier = modifier, attention = attention, ) { @@ -801,6 +813,7 @@ private fun Bubble( minWidth: Dp = 0.dp, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, horizontalPadding: Dp = BubbleDefaults.paddingHorizontal, verticalPadding: Dp = BubbleDefaults.paddingVertical, bare: Boolean = false, @@ -840,9 +853,14 @@ private fun Bubble( .addIf(onClick != null || onLongClick != null) { // combinedClickable rather than two modifiers: a bubble that takes the tap takes // the long press with it, so both gestures are reported from the same target. + // A double tap here waits out the timeout before a single tap acts: that delay is + // the price of the tapback on a bubble whose tap already opens something. Modifier.clip(shape).combinedClickable( onLongClick = onLongClick, + onDoubleClick = onDoubleClick, onClick = { onClick?.invoke() }, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) } .padding( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index e1ef1a3414..d322191f84 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -51,7 +51,10 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties @@ -60,17 +63,34 @@ import kotlinx.coroutines.launch import com.flipcash.shared.chat.reactions.ReactionStrip import androidx.compose.ui.res.stringResource import com.flipcash.core.R +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState // Sizes from the iOS strip (node 9779:105563). -private val StripHeight = 55.dp +private val StripHeight = QuickReactionStripPlacement.StripHeight private val StripMaxWidth = 313.dp private val ItemSize = 40.dp private val ItemSpacing = 4.dp private val Inset = 8.dp -private val AddSize = 38.dp +private val AddSize = ItemSize -/** How far the fade reaches ahead of the "+" before the row draws fully. */ -private val FadeLead = 28.dp +/** + * How far the leading fade reaches in from the strip's edge while the row can scroll back, and how + * much scroll either fade takes to grow in. Either side draws solid when there's nothing more that + * way. + */ +private val EdgeFade = 20.dp + +/** + * The trailing fade, while the row can scroll on: from the middle of the "+" to where its circle is + * last as tall as an emoji. Past that the circle curves in, and an emoji still showing there would + * poke out above and below it, so everything from there to the strip's end is hidden. + */ +private val TrailingFade = 14.dp /** Gap between one emoji starting to pop in and the next, as in the iOS strip's entrance. */ private const val ItemStaggerMillis = 18L @@ -82,6 +102,13 @@ private val PopSpring = spring(dampingRatio = 0.65f, stiffness = 1200f) /** iOS's pre-glass strip surface. */ private val StripSurface = Color(0xFF303030) +/** The "+"'s frost over the emoji passing under it: a blur tinted back toward the strip. */ +private val PlusGlass = HazeBlurStyle { + blurRadius(12.dp) + backgroundColor(StripSurface) + colorEffects(listOf(HazeColorEffect.tint(StripSurface.copy(alpha = 0.6f)))) +} + /** * The strip a long-press on a reactable, selected bubble shows beside it: up to the 12 entries * [ReactionStripComposer][com.flipcash.shared.chat.reactions.ReactionStripComposer] composed, @@ -89,8 +116,10 @@ private val StripSurface = Color(0xFF303030) * A tap on an entry both toggles the reaction and clears the selection — [onToggle] is the one * signal for both. * - * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+", - * fading out ahead of it. + * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+", which + * frosts them with a blur of what passes beneath it. Each end of the strip fades only while there's + * more to scroll that way, growing in over the first [EdgeFade] of travel so the fade doesn't snap on + * at the first pixel. * * It enters as iOS's does: the capsule widens out of the side it hugs ([growsFromEnd] for the * trailing side), the "+" riding its growing edge, and the emoji pop in one after another from @@ -142,31 +171,43 @@ fun QuickReactionStrip( contentAlignment = Alignment.CenterEnd, ) { val density = LocalDensity.current + val scroll = rememberScrollState() + val haze = rememberHazeState() Row( modifier = Modifier .fillMaxHeight() + .hazeSource(haze) .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .drawWithContent { drawContent() - val width = size.width - val inset = Inset.toPx() - val solidUntil = width - inset - AddSize.toPx() - FadeLead.toPx() - val clearFrom = width - inset - AddSize.toPx() / 2 - drawRect( - brush = Brush.horizontalGradient( - 0f to Color.Transparent, - (inset / width) to Color.Black, - (solidUntil / width).coerceAtLeast(inset / width) to Color.Black, - (clearFrom / width) to Color.Transparent, - 1f to Color.Transparent, - ), - blendMode = BlendMode.DstIn, + val fade = EdgeFade.toPx() + val stops = edgeFadeStops( + width = size.width, + leadingFade = fade, + trailingFade = TrailingFade.toPx(), + trailingInset = (Inset + AddSize / 2 - TrailingFade).toPx(), + leading = if (scroll.canScrollBackward) (scroll.value / fade).coerceIn(0f, 1f) else 0f, + trailing = if (scroll.canScrollForward) { + ((scroll.maxValue - scroll.value) / fade).coerceIn(0f, 1f) + } else { + 0f + }, + rtl = layoutDirection == LayoutDirection.Rtl, ) + if (stops.isNotEmpty()) { + drawRect( + brush = Brush.horizontalGradient( + *stops.map { (at, alpha) -> at to Color.Black.copy(alpha = alpha) } + .toTypedArray(), + ), + blendMode = BlendMode.DstIn, + ) + } } .testTag("quick_reaction_scroll") - .horizontalScroll(rememberScrollState()) - // Room to bring the last emoji out from under the "+" and its fade. - .padding(start = Inset, end = Inset + AddSize + FadeLead), + .horizontalScroll(scroll) + // Room to bring the last emoji out from under the "+", one spacing short of it. + .padding(start = Inset, end = Inset + AddSize + ItemSpacing), horizontalArrangement = Arrangement.spacedBy(ItemSpacing), verticalAlignment = Alignment.CenterVertically, ) { @@ -184,10 +225,17 @@ fun QuickReactionStrip( .size(ItemSize) .clip(CircleShape) .background(if (entry.highlighted) Color.White.copy(alpha = 0.18f) else Color.Transparent) - .combinedClickable(onClick = { onToggle(entry.emoji) }, onLongClick = {}), + // A long-press does nothing here, so it plays no haptic either. + .combinedClickable( + onClick = { onToggle(entry.emoji) }, + onLongClick = {}, + hapticFeedbackEnabled = false, + ), contentAlignment = Alignment.Center, ) { - Text(text = entry.emoji, fontSize = with(density) { 28.dp.toSp() }) + // The line box trimmed to the glyph, so the circle centers the emoji rather than + // the font's ascent and descent around it. + Text(text = entry.emoji, style = with(density) { emojiStyle(28.dp.toSp()) }) } } } @@ -206,8 +254,11 @@ fun QuickReactionStrip( .semantics { contentDescription = "More reactions" } .size(AddSize) .clip(CircleShape) + // The emoji scroll on under the "+" and show through it frosted, rather than being + // cut off at its edge. + .hazeBlur(HazeInput.Sources(haze), PlusGlass) .background(Color.White.copy(alpha = 0.18f)) - .combinedClickable(onClick = onOpenPicker, onLongClick = {}), + .combinedClickable(onClick = onOpenPicker, onLongClick = {}, hapticFeedbackEnabled = false), contentAlignment = Alignment.Center, ) { Icon( @@ -222,9 +273,9 @@ fun QuickReactionStrip( /** * [QuickReactionStrip] in a popup placed against [bubbleBounds], the bubble as drawn in the window - * (lift included), falling back to the layout it sits in until that's measured. Placed the way - * iOS places it: above the bubble with a 16dp gap (below when there's no room under [minTop]), - * hugging the sender's side, and never within 16dp of the window's edges. + * (lift included), falling back to the layout it sits in until that's measured. Placed above the + * bubble with a 16dp gap, hugging the sender's side, and never within 16dp of the window's edges. + * The transcript scrolls the bubble down to make that room — see [QuickReactionStripPlacement]. */ @Composable fun QuickReactionStripPopup( @@ -252,7 +303,7 @@ fun QuickReactionStripPopup( strip = popupContentSize, hugsTrailing = hugsTrailing, margin = 16.dp.roundToPx(), - gap = 16.dp.roundToPx(), + gap = QuickReactionStripPlacement.Gap.roundToPx(), minTop = minTop.roundToPx(), ) } @@ -288,8 +339,61 @@ private class RevealCapsule( } } +/** + * The mask [QuickReactionStrip] draws over its scrolling row, as (fraction of [width], alpha) stops + * left to right, or empty when neither end fades. [leading] and [trailing] are each end's strength, + * 0 to 1. The leading fade runs [leadingFade] in from its edge. The trailing one runs [trailingFade] + * and ends [trailingInset] short of the edge, which stays hidden to the same strength. Mirrored for + * [rtl], where the leading edge is the right one. + */ +internal fun edgeFadeStops( + width: Float, + leadingFade: Float, + trailingFade: Float, + trailingInset: Float, + leading: Float, + trailing: Float, + rtl: Boolean, +): List> { + if (width <= 0f || (leading <= 0f && trailing <= 0f)) return emptyList() + val solidFrom = (leadingFade / width).coerceIn(0f, 1f) + val clear = (1f - trailingInset / width).coerceIn(solidFrom, 1f) + val solidUntil = (clear - trailingFade / width).coerceIn(solidFrom, clear) + val stops = listOf( + 0f to 1f - leading, + solidFrom to 1f, + solidUntil to 1f, + clear to 1f - trailing, + 1f to 1f - trailing, + ) + return if (rtl) stops.reversed().map { (at, alpha) -> (1f - at) to alpha } else stops +} + +/** An emoji's text style with the line box trimmed to [size], which centers the glyph in its box. */ +private fun emojiStyle(size: TextUnit) = TextStyle( + fontSize = size, + lineHeight = size, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), +) + /** Where [QuickReactionStripPopup] puts the strip, in window pixels. */ object QuickReactionStripPlacement { + /** The strip's height, which the transcript makes room for above a bubble it lifts. */ + val StripHeight = 55.dp + + /** Between the strip and the bubble under it. */ + val Gap = 16.dp + + /** + * Above the bubble, [gap] clear of it. A bubble that sits too high for that is scrolled down by + * the transcript, and until it gets there the strip waits at [minTop] rather than flipping + * below and back. Only a bubble too tall to leave room for the strip on screen at all gets the + * strip under it. + */ fun position( anchor: IntRect, window: IntSize, @@ -306,13 +410,15 @@ object QuickReactionStripPlacement { } val x = hugged.coerceIn(margin, maxOf(margin, window.width - margin - strip.width)) val above = anchor.top - gap - strip.height - val y = if (above >= minTop) above else anchor.bottom + gap + val tooTall = anchor.height > window.height - minTop - gap - strip.height + val y = if (tooTall && above < minTop) anchor.bottom + gap else above return IntOffset(x, y.coerceIn(minTop, maxOf(minTop, window.height - strip.height))) } /** * The anchor for a message drawn as several rows -- one split around its link card -- so the - * strip sits above the top row, or below the bottom one, rather than between two of them. + * strip sits above the top row, or below the bottom one of a message too tall for that, rather + * than between two of them. * `null` until a row has been measured. */ fun messageBounds(rows: Collection): IntRect? = rows.reduceOrNull { union, row -> diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index ed00c35b9b..fc2687913f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -574,7 +574,7 @@ private fun MorePillChip( .let { base -> if (onClick != null) base.testTag("reaction_pill_more") else base } .clip(shape) .background(color = Color.White.copy(alpha = 0.06f), shape = shape) - .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}) } ?: base } + .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}, hapticFeedbackEnabled = false) } ?: base } .height(height) .padding(horizontal = horizontalPadding), contentAlignment = Alignment.Center, @@ -596,7 +596,7 @@ private fun PlusChip(size: Dp, onClick: () -> Unit, modifier: Modifier = Modifie .size(size) .clip(CircleShape) .background(color = Color.White.copy(alpha = 0.18f), shape = CircleShape) - .combinedClickable(onClick = onClick, onLongClick = {}), + .combinedClickable(onClick = onClick, onLongClick = {}, hapticFeedbackEnabled = false), contentAlignment = Alignment.Center, ) { // The strip's add-reaction icon, as iOS reuses its strip asset here. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt index 318fdef210..d6c8bd6fde 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt @@ -67,6 +67,7 @@ internal fun UserLinkCard( modifier: Modifier = Modifier, shape: Shape = GroupInviteCardDefaults.SHAPE, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val state = card.state @@ -97,11 +98,22 @@ internal fun UserLinkCard( .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, shape) .then( when { - tap != null -> Modifier.combinedClickable(onLongClick = onLongClick, onClick = tap) - // Nothing to open, but a long press still has to reach the transcript. - onLongClick != null -> Modifier.pointerInput(onLongClick) { - detectTapGestures(onLongPress = { onLongClick() }) - } + tap != null -> Modifier.combinedClickable( + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onClick = tap, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, + ) + // Nothing to open, but a long press and a double tap still have to reach the + // transcript. + onLongClick != null || onDoubleClick != null -> + Modifier.pointerInput(onLongClick, onDoubleClick) { + detectTapGestures( + onLongPress = { onLongClick?.invoke() }, + onDoubleTap = onDoubleClick?.let { double -> { double() } }, + ) + } else -> Modifier }, ) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt index d075accbe1..00323a29c7 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.unit.IntSize import org.junit.Test import kotlin.test.assertEquals -/** [QuickReactionStripPlacement]: iOS's strip placement, beside the long-pressed bubble. */ +/** [QuickReactionStripPlacement]: the strip above the lifted bubble, hugging the sender's side. */ class QuickReactionStripPlacementTest { private val window = IntSize(1000, 2000) @@ -60,13 +60,32 @@ class QuickReactionStripPlacementTest { } @Test - fun `goes below the bubble when there is no room above`() { + fun `stays above a bubble that has no room yet, held at the top`() { + // The transcript scrolls the bubble down to make room; until then the strip waits at minTop + // rather than flipping below and back. assertEquals( - IntOffset(120, 600 + 40), + IntOffset(120, 300), place(IntRect(left = 120, top = 350, right = 500, bottom = 600), hugsTrailing = false), ) } + @Test + fun `goes below a bubble too tall to leave room for it above`() { + // 2000 - 300 (minTop) - 40 (gap) - 100 (strip) = 1560 is the tallest bubble it can sit over. + assertEquals( + IntOffset(120, 1700 + 40), + place(IntRect(left = 120, top = 100, right = 500, bottom = 1700), hugsTrailing = false), + ) + } + + @Test + fun `a bubble exactly as tall as the room left keeps the strip above`() { + assertEquals( + IntOffset(120, 300), + place(IntRect(left = 120, top = 440, right = 500, bottom = 2000), hugsTrailing = false), + ) + } + @Test fun `a message split around its card is one anchor spanning its rows`() { // "Join my group ": a text bubble over a wider card, both outgoing. @@ -81,13 +100,13 @@ class QuickReactionStripPlacementTest { } @Test - fun `a split message with no room above puts the strip below its last row`() { - val text = IntRect(left = 120, top = 320, right = 400, bottom = 420) - val card = IntRect(left = 120, top = 440, right = 680, bottom = 900) + fun `a split message too tall for the strip above puts it below its last row`() { + val text = IntRect(left = 120, top = 200, right = 400, bottom = 300) + val card = IntRect(left = 120, top = 320, right = 680, bottom = 1800) val anchor = QuickReactionStripPlacement.messageBounds(listOf(text, card))!! - assertEquals(IntOffset(120, 900 + 40), place(anchor, hugsTrailing = false)) + assertEquals(IntOffset(120, 1800 + 40), place(anchor, hugsTrailing = false)) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt index dffbc1f1e3..20540a9dda 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt @@ -3,14 +3,19 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.unit.dp import com.flipcash.shared.chat.reactions.ReactionStrip import com.getcode.theme.DesignSystem @@ -90,6 +95,64 @@ class QuickReactionStripTest { composeTestRule.runOnIdle { assertEquals(1, opened) } } + @Test + fun `plus is the size of an emoji circle`() { + setStrip(entries = listOf(entry("❤️"))) + + composeTestRule.onNodeWithTag("quick_reaction_plus") + .assertWidthIsEqualTo(40.dp) + .assertHeightIsEqualTo(40.dp) + } + + @Test + fun `scrolled to the end, the last entry sits one spacing short of the plus`() { + val entries = List(11) { entry("😀") } + entry("🔥") + setStrip(entries = entries, width = 313.dp) + + composeTestRule.onNodeWithTag("quick_reaction_scroll").performTouchInput { swipeLeft() } + composeTestRule.onNodeWithTag("quick_reaction_scroll").performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() + + val last = composeTestRule.onNodeWithTag("quick_reaction_🔥").getUnclippedBoundsInRoot() + val plus = composeTestRule.onNodeWithTag("quick_reaction_plus").getUnclippedBoundsInRoot() + assertEquals(4f, (plus.left - last.right).value, absoluteTolerance = 0.5f) + } + + @Test + fun `no fade at either end with nowhere to scroll`() { + assertEquals( + emptyList(), + edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 0f, trailing = 0f, rtl = false), + ) + } + + @Test + fun `the trailing fade ends inside the plus and hides everything past it`() { + val clear = 1f - 14f / 300 + assertEquals( + listOf(0f to 1f, 20f / 300 to 1f, clear - 14f / 300 to 1f, clear to 0f, 1f to 0f), + edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 0f, trailing = 1f, rtl = false), + ) + } + + @Test + fun `the leading fade grows in with the scroll`() { + assertEquals( + listOf(0f to 0.5f, 20f / 300 to 1f, (1f - 14f / 300) - 14f / 300 to 1f, 1f - 14f / 300 to 1f, 1f to 1f), + edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 0.5f, trailing = 0f, rtl = false), + ) + } + + @Test + fun `both fades mirror in RTL`() { + val ltr = edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 1f, trailing = 1f, rtl = false) + val rtl = edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 1f, trailing = 1f, rtl = true) + + assertEquals(ltr.reversed().map { (at, alpha) -> (1f - at) to alpha }, rtl) + // The leading edge is the right one, and it fades. + assertEquals(1f to 0f, rtl.last()) + } + @Test fun `overflowing entries are reachable by horizontal scroll`() { // The strip doesn't dedupe entries, so 29 identical filler emoji plus one distinguishable diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index a8ebd3c7a9..5f04955bb2 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -87,8 +87,8 @@ private val WrapContent = SheetDetent("wrap-content") { containerHeight, sheetHe * * The sheet always fills this detent, so its content is measured against it and does not need to * restate the fraction. [Expanded] joins the detent list only once the content reports that it - * overruns this one, so a sheet with nothing below the fold can't be dragged up into dead space — - * see [com.getcode.ui.utils.LocalSheetExpansionState]. + * overruns this one, so a sheet with nothing below the fold can't be dragged up into dead space, and + * stays in it from then on — see [com.getcode.ui.utils.LocalSheetExpansionState]. */ private val Half = SheetDetent("half") { containerHeight, _ -> containerHeight * 0.5f @@ -182,8 +182,13 @@ internal class ModalBottomSheetScene constructor( var contentOverflows by remember { mutableStateOf(false) } // Kept stable: this backs a static local, so a fresh lambda each pass would // needlessly invalidate the whole sheet subtree. + // + // Latched: once the content overruns [restingDetent], the sheet keeps [Expanded]. A list + // that overflows at Half but fits at Expanded stops reporting overflow the moment the + // sheet grows to hold it, and withdrawing the detent then drops the sheet back to Half, + // where the list overflows again -- the sheet never stays expanded. val setContentOverflows = remember { { overflows: Boolean -> - contentOverflows = overflows + if (overflows) contentOverflows = true } } val sheetState = rememberBottomSheetState( diff --git a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt index 29830823bd..c0cb958922 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt @@ -13,15 +13,19 @@ import androidx.compose.runtime.staticCompositionLocalOf * * A [com.getcode.navigation.HalfSheet] rests at half the screen, so content that fits in that half * is already showing everything it has; dragging up would only reveal empty space. The host - * therefore withholds the expanded detent until content reports `true` here. + * therefore withholds the expanded detent until content reports `true` here, and keeps it from then + * on: content that fits once the sheet has grown reports `false`, and dropping the detent at that + * point would send the sheet back to the half where the content overflows again. */ val LocalSheetExpansionState = staticCompositionLocalOf<(Boolean) -> Unit> { { } } /** * Reports the host sheet as expandable for as long as [scrollState] has content out of view. * - * A list that can't scroll at the resting detent has nothing to gain from expanding. The report is - * reset when the content leaves composition so the next sheet starts from its own measurement. + * A list that can't scroll at the resting detent has nothing to gain from expanding. Only a `true` + * report changes anything: the host keeps the expanded detent for the rest of the sheet's life once + * it has been offered, so the `false` this sends when the list fits, or leaves composition, is + * ignored. */ @Composable fun AllowSheetExpansionWhenScrollable(scrollState: LazyListState) {