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 86ee87c93..f1da2fa4f 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 @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay import com.flipcash.shared.chat.ui.media.ChatPhotoSources +import com.flipcash.shared.chat.ui.media.chatPhotoTranscript import com.flipcash.shared.chat.ui.media.LocalChatMediaProgress import com.flipcash.shared.chat.ui.media.LocalChatPhotoSources import androidx.compose.runtime.LaunchedEffect @@ -412,7 +413,14 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { .fillMaxSize() .testTag("chat_message_list") .softTopEdge(ChatTopEdge.blurHold(barHeight)) - .hazeSource(hazeState), + .hazeSource(hazeState) + // Inside the edge blur and the haze, so a photo landing under the bars + // passes behind them the way its bubble does. + .chatPhotoTranscript( + sources = photoSources, + top = barHeight, + bottom = overlapPadding.calculateBottomPadding(), + ), state = state, contentPadding = overlapPadding, messages = messages, 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 6cd433103..6571a60d4 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 @@ -52,6 +52,10 @@ fun ChatQuotePanel( onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, + /** A solid fill under the tint, for a panel that sits on a photo rather than a filled bubble. */ + ground: Color? = null, + /** Overrides the panel's own corners, for a panel nested in something other than a text bubble. */ + shape: Shape? = null, ) { val accent = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: accent @@ -61,7 +65,8 @@ fun ChatQuotePanel( // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a // fillMaxHeight child in an unbounded Row measures to zero. modifier = modifier - .clip(QuotePanelDefaults.shape) + .clip(shape ?: QuotePanelDefaults.shape) + .addIf(ground != null) { Modifier.background(ground!!) } // The author's own colour at low alpha rather than a neutral scrim: the panel sits on a // filled bubble, and tinting it to match the rule is what separates the two surfaces. .background(accent.copy(alpha = QuotePanelDefaults.groundAlpha)) 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 c5b037316..050b829bd 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 @@ -948,7 +948,7 @@ internal fun Bubble( drawRect(Color.White, alpha = ATTENTION_SCRIM_ALPHA * strength) } } - .addIf(onClick != null || onLongClick != null) { + .addIf(onClick != null || onLongClick != null || onDoubleClick != 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 @@ -972,12 +972,25 @@ internal fun Bubble( @Composable fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerShape { + val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) + + val targets = bubbleCorners(position, isFromSelf) + + val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS") + val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE") + val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE") + val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS") + + return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) +} + +/** The corners [bubbleShape] settles on for a bubble at [position]. */ +@Composable +internal fun bubbleCorners(position: BubblePosition, isFromSelf: Boolean): BubbleCorners { val l = BubbleDefaults.cornerLarge val s = BubbleDefaults.cornerSmall - val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) - - val targets = when (position) { + return when (position) { BubblePosition.Solo -> BubbleCorners(l, l, l, l) BubblePosition.First -> if (isFromSelf) BubbleCorners(l, l, s, l) else BubbleCorners( l, @@ -1000,16 +1013,9 @@ fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerSha l ) } - - val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS") - val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE") - val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE") - val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS") - - return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) } -private data class BubbleCorners( +internal data class BubbleCorners( val topStart: Dp, val topEnd: Dp, val bottomEnd: Dp, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 203878a05..508638ec7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -33,6 +34,8 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.layout @@ -93,6 +96,7 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * @param frame the photo's rect, corners and the opacities, read at layout and draw time so a * host can animate them without recomposing. Null fills the container. * @param interactive false while the host is closing the viewer: touches and buttons do nothing. + * @param onPainter receives the painter the photo is drawn with, for a host that draws a copy of it. */ @Composable fun ChatMediaViewer( @@ -109,6 +113,7 @@ fun ChatMediaViewer( onPull: ((Offset) -> Unit)? = null, onPullEnd: ((Offset) -> Unit)? = null, interactive: Boolean = true, + onPainter: ((Painter) -> Unit)? = null, ) { val scope = rememberCoroutineScope() val dragY = remember { Animatable(0f) } @@ -128,6 +133,7 @@ fun ChatMediaViewer( } }, ) + if (onPainter != null) SideEffect { onPainter(painter) } Box( modifier = modifier @@ -225,6 +231,16 @@ fun ChatMediaViewer( ) { Box( modifier = Modifier + .drawWithContent { + // In the container's coordinates, before the photo is placed in it: keeps a + // photo landing on a bubble under the chat's bars behind them, as the bubble is. + val clip = frame?.invoke()?.clip + if (clip == null) { + drawContent() + } else { + clipRect(clip.left, clip.top, clip.right, clip.bottom) { this@drawWithContent.drawContent() } + } + } .layout { measurable, constraints -> // The photo's own frame inside the container, which a host animates; the // whole container when it does not. @@ -245,9 +261,11 @@ fun ChatMediaViewer( translationY = zoomState.offset.y + if (frame == null) dragY.value else 0f if (frame != null) { val f = frame() - alpha = f.contentAlpha - shape = RoundedCornerShape(f.radiusPx) - clip = f.radiusPx > 0f + // The transcript draws the rest, under the chat's bars. + alpha = f.contentAlpha * (1f - f.handoff) + val c = f.corners + shape = RoundedCornerShape(c.topStart, c.topEnd, c.bottomEnd, c.bottomStart) + clip = !c.isSquare } }, ) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt index f7793deb8..3493abc83 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size @@ -48,7 +49,8 @@ private const val STIFFNESS = 439f internal class ChatPhotoPresentation( private val scope: CoroutineScope, private val lookup: () -> Rect?, - private val sourceRadiusPx: Float, + private val sourceCorners: ChatPhotoCorners, + private val transcript: () -> Rect?, private val content: () -> Size, private val density: Float, private val reduceMotion: Boolean, @@ -95,7 +97,15 @@ internal class ChatPhotoPresentation( t = ChatPhotoTransition.dragProgress(offset, size.height) anchor = ChatPhotoTransition.draggedRect(open, offset, t) } - return ChatPhotoTransition.frame(source, sourceRadiusPx, anchor, t, progress.value) + return ChatPhotoTransition.frame( + source = source, + sourceCorners = sourceCorners, + anchor = anchor, + anchorPull = t, + progress = progress.value, + transcript = transcript()?.translate(-origin), + container = size, + ) } suspend fun open() { @@ -168,7 +178,8 @@ fun ChatPhotoOverlay( ChatPhotoPresentation( scope = scope, lookup = { source.boundsInRoot() }, - sourceRadiusPx = source.radiusPx, + sourceCorners = source.corners, + transcript = { sources.transcript }, content = { source.imageSize ?: zoomState.content.takeIf { it.width > 0f && it.height > 0f } @@ -186,6 +197,12 @@ fun ChatPhotoOverlay( sources.hiddenId = messageId onDispose { if (sources.hiddenId == messageId) sources.hiddenId = null } } + var painter by remember(messageId) { mutableStateOf(null) } + DisposableEffect(sources, presentation, painter) { + val landing = painter?.let { ChatPhotoLanding(it, presentation::frame) { presentation.origin } } + sources.landing = landing + onDispose { if (sources.landing === landing) sources.landing = null } + } LaunchedEffect(presentation) { presentation.open() } BackHandler { presentation.dismiss() } @@ -217,6 +234,7 @@ fun ChatPhotoOverlay( onPull = presentation::onPull, onPullEnd = presentation::onPullEnd, interactive = !presentation.closing, + onPainter = { painter = it }, ) } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt index dbd25b7e5..92d708d20 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt @@ -9,15 +9,30 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.toSize import androidx.compose.ui.platform.LocalDensity import com.flipcash.shared.chat.media.ChatPhoto -import com.flipcash.shared.chat.ui.BubbleDefaults +import androidx.compose.ui.unit.Dp +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.bubbleCorners /** What the overlay needs of one on-screen photo bubble: where it is, and what it draws. */ @Stable @@ -25,14 +40,23 @@ class ChatPhotoSource internal constructor( internal val model: ChatPhoto, /** The photo's own size, for the shape it opens to. Null when the message did not say. */ internal val imageSize: Size?, - internal val radiusPx: Float, + internal val corners: ChatPhotoCorners, ) { internal var coordinates: LayoutCoordinates? = null - /** The bubble's bounds in the root's coordinates, or null when it is not on screen. */ - internal fun boundsInRoot(): Rect? = coordinates?.takeIf { it.isAttached }?.boundsInRoot() + /** + * The bubble's bounds in the root's coordinates, or null when it is not on screen. Unclipped: + * a bubble running under the top bar extends past the list's edge, and the photo lands on all of it. + */ + internal fun boundsInRoot(): Rect? = coordinates?.takeIf { it.isAttached }?.unclippedBoundsInRoot() } +/** + * This layout's own bounds in the root's coordinates. [boundsInRoot] clips them to every ancestor, + * so a bubble scrolled partly out of the list reports only its visible part. + */ +internal fun LayoutCoordinates.unclippedBoundsInRoot(): Rect = Rect(positionInRoot(), size.toSize()) + /** * The photo bubbles currently composed in the transcript, by message id. The overlay reads a * bubble's bounds when it opens and again when it closes, since the row may have moved in between, @@ -42,9 +66,18 @@ class ChatPhotoSource internal constructor( class ChatPhotoSources { private val sources = HashMap() + /** + * The part of the chat its top and bottom bars do not cover, in the root's coordinates. A + * bubble under a bar is drawn behind it, so the photo landing there is clipped to this. + */ + internal var transcript: Rect? = null + /** The message whose bubble is covered by the overlay and drawn at zero alpha. */ var hiddenId by mutableStateOf(null) + /** The open photo, for the transcript to draw its share of near the bubble. */ + internal var landing by mutableStateOf(null) + internal fun register(id: Long, source: ChatPhotoSource) { sources[id] = source } @@ -69,17 +102,17 @@ internal fun Modifier.chatPhotoSource( model: ChatPhoto?, imageWidth: Int?, imageHeight: Int?, - radiusPx: Float, + corners: ChatPhotoCorners, ): Modifier { val sources = LocalChatPhotoSources.current if (sources == null || messageId == null || model == null) return this - val source = remember(model, imageWidth, imageHeight, radiusPx) { + val source = remember(model, imageWidth, imageHeight, corners) { val size = if ((imageWidth ?: 0) > 0 && (imageHeight ?: 0) > 0) { Size(imageWidth!!.toFloat(), imageHeight!!.toFloat()) } else { null } - ChatPhotoSource(model, size, radiusPx) + ChatPhotoSource(model, size, corners) } DisposableEffect(sources, messageId, source) { sources.register(messageId, source) @@ -90,5 +123,82 @@ internal fun Modifier.chatPhotoSource( .graphicsLayer { alpha = if (sources.hiddenId == messageId) 0f else 1f } } +/** The corners a photo bubble at [position] settles on, in px. */ +@Composable +internal fun bubblePhotoCorners(position: BubblePosition, isFromSelf: Boolean): ChatPhotoCorners { + val c = bubbleCorners(position, isFromSelf) + return with(LocalDensity.current) { + ChatPhotoCorners(c.topStart.toPx(), c.topEnd.toPx(), c.bottomEnd.toPx(), c.bottomStart.toPx()) + } +} + +/** + * The photo the overlay is flying: what it draws, and where, in the overlay's coordinates at + * [origin] in the root. + */ +internal class ChatPhotoLanding( + val painter: Painter, + val frame: () -> ChatMediaFrame, + val origin: () -> Offset, +) + +/** + * Reports the part of this chat its bars do not cover to [LocalChatPhotoSources]: these bounds + * less [top] and [bottom]. Also draws the open photo's [ChatMediaFrame.handoff] share over the + * transcript, so apply it inside any blur or haze the bars put on the transcript: the photo then + * passes under them the way its bubble does. + */ @Composable -internal fun bubblePhotoRadiusPx(): Float = with(LocalDensity.current) { BubbleDefaults.cornerLarge.toPx() } +fun Modifier.chatPhotoTranscript(sources: ChatPhotoSources, top: Dp, bottom: Dp): Modifier { + val density = LocalDensity.current + var origin = Offset.Zero + return this + .onGloballyPositioned { + val bounds = it.boundsInRoot() + origin = bounds.topLeft + with(density) { + sources.transcript = Rect(bounds.left, bounds.top + top.toPx(), bounds.right, bounds.bottom - bottom.toPx()) + } + } + .drawWithContent { + drawContent() + val landing = sources.landing ?: return@drawWithContent + val frame = landing.frame() + if (frame.handoff <= 0f) return@drawWithContent + translate(landing.origin().x - origin.x, landing.origin().y - origin.y) { + drawChatPhoto(landing.painter, frame.rect, frame.corners, frame.contentAlpha * frame.handoff) + } + } +} + +/** + * [painter] cropped to fill [rect] with [corners], as the overlay's `Image` draws it with + * [ContentScale.Crop], so the two copies line up while one fades into the other. + */ +internal fun DrawScope.drawChatPhoto(painter: Painter, rect: Rect, corners: ChatPhotoCorners, alpha: Float) { + if (rect.width <= 0f || rect.height <= 0f || alpha <= 0f) return + val ltr = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Ltr + val path = Path().apply { + addRoundRect( + RoundRect( + rect = rect, + topLeft = CornerRadius(if (ltr) corners.topStart else corners.topEnd), + topRight = CornerRadius(if (ltr) corners.topEnd else corners.topStart), + bottomRight = CornerRadius(if (ltr) corners.bottomEnd else corners.bottomStart), + bottomLeft = CornerRadius(if (ltr) corners.bottomStart else corners.bottomEnd), + ), + ) + } + val src = painter.intrinsicSize + val drawn = if (src.isUnspecified || src.width <= 0f || src.height <= 0f) { + rect.size + } else { + val scale = ContentScale.Crop.computeScaleFactor(src, rect.size) + Size(src.width * scale.scaleX, src.height * scale.scaleY) + } + clipPath(path) { + translate(rect.left + (rect.width - drawn.width) / 2f, rect.top + (rect.height - drawn.height) / 2f) { + with(painter) { draw(drawn, alpha) } + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt index a4472fbbd..8d23fe05c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt @@ -10,26 +10,73 @@ import androidx.compose.ui.util.lerp * Where the photo is drawn at one instant of the overlay's open, drag and close, in the overlay's * own coordinates. * - * @property radiusPx corner radius of [rect]. + * @property corners corner radii of [rect]. * @property contentAlpha opacity of the photo; below 1 only when there is no bubble to fly to or from. * @property backdrop opacity of the black behind it. * @property chromeAlpha opacity of the close and share buttons. + * @property clip where the overlay may draw the photo; null for anywhere. + * @property handoff how much of the photo the transcript draws instead of the overlay, from 0 to 1. + * The transcript's copy sits under the bars' blur and fade the way the bubble does, so the photo + * lands looking like the bubble rather than changing as the overlay goes. */ data class ChatMediaFrame( val rect: Rect, - val radiusPx: Float, + val corners: ChatPhotoCorners, val contentAlpha: Float, val backdrop: Float, val chromeAlpha: Float, + val clip: Rect? = null, + val handoff: Float = 0f, ) { companion object { /** Nothing drawn: the overlay has no size yet. */ - val Hidden = ChatMediaFrame(Rect.Zero, 0f, 0f, 0f, 0f) + val Hidden = ChatMediaFrame(Rect.Zero, ChatPhotoCorners.uniform(0f), 0f, 0f, 0f) } } +/** A photo's corner radii in px, start and end following the layout direction like [androidx.compose.foundation.shape.RoundedCornerShape]. */ +data class ChatPhotoCorners( + val topStart: Float, + val topEnd: Float, + val bottomEnd: Float, + val bottomStart: Float, +) { + val max: Float get() = maxOf(maxOf(topStart, topEnd), maxOf(bottomEnd, bottomStart)) + + val isSquare: Boolean get() = max <= 0f + + companion object { + fun uniform(radius: Float) = ChatPhotoCorners(radius, radius, radius, radius) + } +} + +internal fun lerp(start: ChatPhotoCorners, stop: ChatPhotoCorners, fraction: Float) = ChatPhotoCorners( + topStart = lerp(start.topStart, stop.topStart, fraction), + topEnd = lerp(start.topEnd, stop.topEnd, fraction), + bottomEnd = lerp(start.bottomEnd, stop.bottomEnd, fraction), + bottomStart = lerp(start.bottomStart, stop.bottomStart, fraction), +) + /** The pure parts of the photo overlay: the pull's geometry, the release decision, the rect blend. */ internal object ChatPhotoTransition { + /** + * The last stretch of the open over which the clip widens from the transcript to the screen. + * Spread over the whole open, the clip trails a photo that crosses the bars early in its + * flight, and the photo is drawn over them until it nearly lands. + */ + private const val CLIP_RELEASE = 0.2f + + private fun clipProgress(p: Float) = (1f - (1f - p) / CLIP_RELEASE).coerceIn(0f, 1f) + + /** + * The first stretch of the open over which the transcript hands the photo to the overlay, and + * the last of the close over which it takes it back. Short, since the transcript's copy is + * under the backdrop, which is near clear only here. + */ + private const val HANDOFF = 0.2f + + private fun handoff(p: Float) = (1f - p / HANDOFF).coerceIn(0f, 1f) + /** A pull of this fraction of the container's height takes the shrink and the fade to their ends. */ const val DRAG_FULL_FRACTION = 0.5f @@ -94,31 +141,38 @@ internal object ChatPhotoTransition { * mid-pull rect. One progress drives both directions, so the target ([source] or [anchor]) may * move while it runs. With no [source] the photo grows from, and shrinks to, its own center while fading. * + * @param sourceCorners the bubble's own corners, which can differ where it joins a run. * @param anchorPull how far into a pull [anchor] is; the corners round and the backdrop and buttons fade with it. + * @param transcript where the chat is not covered by its bars. The photo is clipped to it on + * the bubble, the way the bubble is, and to the whole [container] when open. Null clips nothing. */ fun frame( source: Rect?, - sourceRadius: Float, + sourceCorners: ChatPhotoCorners, anchor: Rect, anchorPull: Float, progress: Float, + transcript: Rect? = null, + container: Size = Size.Zero, ): ChatMediaFrame { val p = progress.coerceIn(0f, 1f) - val anchorRadius = if (source != null) sourceRadius * anchorPull else 0f + val anchorCorners = ChatPhotoCorners.uniform(if (source != null) sourceCorners.max * anchorPull else 0f) val backdrop = dragBackdrop(anchorPull) * p val chrome = (1f - anchorPull) * p return if (source != null) { ChatMediaFrame( rect = lerp(source, anchor, p), - radiusPx = lerp(sourceRadius, anchorRadius, p), + corners = lerp(sourceCorners, anchorCorners, p), contentAlpha = 1f, backdrop = backdrop, chromeAlpha = chrome, + clip = transcript?.let { lerp(it, Rect(Offset.Zero, container), clipProgress(p)) }, + handoff = if (transcript != null) handoff(p) else 0f, ) } else { ChatMediaFrame( rect = lerp(scaledAbout(anchor, DETACHED_SCALE), anchor, p), - radiusPx = anchorRadius, + corners = anchorCorners, contentAlpha = p, backdrop = backdrop, chromeAlpha = chrome, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt index d89184c68..c9584a94b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -11,9 +11,18 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.max +import androidx.compose.foundation.shape.RoundedCornerShape +import com.flipcash.shared.chat.ui.BubbleDefaults +import com.flipcash.shared.chat.ui.bubbleCorners +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.Dp import com.getcode.theme.CodeTheme +import com.flipcash.shared.chat.ui.BubbleCorners import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.MessageContent @@ -144,21 +153,7 @@ internal fun MediaMessageBubble( verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - if (quote != null) { - Bubble( - isFromSelf = item.isFromSelf, - position = position.withCaptionBelow(), - maxWidth = maxWidth, - onLongClick = onLongClick, - ) { - ChatQuotePanel( - quote = quote, - onClick = onQuoteClick, - onLongClick = onLongClick, - onDoubleClick = onDoubleClick, - ) - } - } + Box { ChatPhotoBubble( isFromSelf = item.isFromSelf, maxWidth = maxWidth, @@ -169,7 +164,7 @@ internal fun MediaMessageBubble( localModel = localUri, sentPreview = sentPreview, caption = body.caption?.text, - position = if (quote != null) position.withPhotoAbove() else position, + position = position, blurhashOnly = blurhashOnly, unavailable = unavailable, progress = progress?.let { p -> @@ -181,7 +176,11 @@ internal fun MediaMessageBubble( model = model, imageWidth = image?.width, imageHeight = image?.height, - radiusPx = bubblePhotoRadiusPx(), + corners = bubblePhotoCorners( + position = position + .let { if (body.caption?.text.isNullOrEmpty()) it else it.withCaptionBelow() }, + isFromSelf = item.isFromSelf, + ), ), onClick = onClick, onLongClick = onLongClick, @@ -191,5 +190,59 @@ internal fun MediaMessageBubble( loadError = it }, ) + // The citation rides on the photo rather than in a bubble of its own above it, so the + // reply reads as one message. Matched to the photo's box, not the row, so a long quote + // wraps inside the photo instead of measuring against the row's full width. + if (quote != null) { + Box(Modifier.matchParentSize()) { + // Tighter than a text reply's surround: at the bubble's 12dp corner an 8dp gap + // leaves the chip a 4dp corner, which reads as square rather than parallel. + val inset = CodeTheme.dimens.staticGrid.x1 + val chip = photoQuoteCorners( + photoTopStart = bubbleCorners(position, item.isFromSelf).topStart, + large = BubbleDefaults.cornerLarge, + small = BubbleDefaults.cornerSmall, + inset = inset, + ) + Box( + modifier = Modifier + .fillMaxWidth(PhotoQuoteDefaults.maxWidthFraction) + .padding(inset), + ) { + ChatQuotePanel( + modifier = Modifier.testTag(PHOTO_QUOTE_TAG), + quote = quote, + shape = RoundedCornerShape(chip.topStart, chip.topEnd, chip.bottomEnd, chip.bottomStart), + // Near-opaque so the snippet reads on any photo, light or dark. + ground = CodeTheme.colors.background.copy(alpha = PhotoQuoteDefaults.groundAlpha), + onClick = onQuoteClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) + } + } + } + } } } + +/** The quote a photo reply carries, drawn over the photo's top corner. */ +internal const val PHOTO_QUOTE_TAG = "chat_photo_quote" + +private object PhotoQuoteDefaults { + /** The share of the photo's width a citation may take, so some of the photo always shows beside it. */ + const val maxWidthFraction = 0.8f + const val groundAlpha = 0.85f +} + +/** + * The chip's corners, concentric with the photo it sits on: the corner nested in the photo's + * top-start follows that corner less the inset, so the gap holds round the turn whether the photo + * is rounded or flattened by its group. The other three are free-standing and take the radius a + * rounded corner would give, so a solo photo's chip is uniform. Floored at the flattened radius, + * as the text reply's panel is. + */ +internal fun photoQuoteCorners(photoTopStart: Dp, large: Dp, small: Dp, inset: Dp): BubbleCorners { + val free = max(large - inset, small) + return BubbleCorners(max(photoTopStart - inset, small), free, free, free) +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt new file mode 100644 index 000000000..60d2dd9fb --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt @@ -0,0 +1,47 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class ChatPhotoSourceBoundsTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun `a bubble scrolled partly past the list's top keeps its full bounds`() { + var bounds: Rect? = null + var px = 0f + rule.setContent { + px = with(LocalDensity.current) { 1.dp.toPx() } + Box(Modifier.offset(y = 100.dp).size(200.dp).clipToBounds()) { + Box( + Modifier + .offset(y = (-40).dp) + .size(width = 120.dp, height = 80.dp) + .onGloballyPositioned { bounds = it.unclippedBoundsInRoot() }, + ) + } + } + rule.waitForIdle() + + val b = bounds!! + assertEquals(60 * px, b.top, 0.5f) + assertEquals(80 * px, b.height, 0.5f) + assertEquals(120 * px, b.width, 0.5f) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt index 4d075d3db..0070f1037 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt @@ -15,48 +15,104 @@ class ChatPhotoTransitionTest { @Test fun `progress zero sits on the bubble and one on the anchor`() { - val start = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 0f) + val start = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 0f) assertEquals(bubble, start.rect) - assertEquals(48f, start.radiusPx) + assertEquals(ChatPhotoCorners.uniform(48f), start.corners) assertEquals(0f, start.backdrop) - val end = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 1f) + val end = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 1f) assertEquals(open, end.rect) - assertEquals(0f, end.radiusPx) + assertEquals(ChatPhotoCorners.uniform(0f), end.corners) assertEquals(1f, end.backdrop) assertEquals(1f, end.chromeAlpha) } @Test fun `halfway is the midpoint of both rects and corners`() { - val mid = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 0.5f) + val mid = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 0.5f) assertEquals(Rect(300f, 500f, 1150f, 1275f), mid.rect) - assertEquals(24f, mid.radiusPx) + assertEquals(ChatPhotoCorners.uniform(24f), mid.corners) assertEquals(0.5f, mid.backdrop) } @Test fun `a pulled anchor keeps its corners and dims the backdrop`() { - val pulled = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 1f, progress = 1f) - assertEquals(48f, pulled.radiusPx) + val pulled = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 1f, progress = 1f) + assertEquals(ChatPhotoCorners.uniform(48f), pulled.corners) assertEquals(ChatPhotoTransition.MIN_DRAG_BACKDROP, pulled.backdrop) assertEquals(0f, pulled.chromeAlpha) } @Test fun `without a bubble the photo fades and shrinks about its own center`() { - val gone = ChatPhotoTransition.frame(null, 48f, open, anchorPull = 0f, progress = 0f) + val gone = ChatPhotoTransition.frame(null, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 0f) assertEquals(0f, gone.contentAlpha) assertEquals(open.center, gone.rect.center) assertEquals(open.width * ChatPhotoTransition.DETACHED_SCALE, gone.rect.width) - val shown = ChatPhotoTransition.frame(null, 48f, open, anchorPull = 0f, progress = 1f) + val shown = ChatPhotoTransition.frame(null, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 1f) assertEquals(1f, shown.contentAlpha) assertEquals(open, shown.rect) } + @Test + fun `each corner starts on the bubble's own corner and opens square`() { + val tail = ChatPhotoCorners(topStart = 48f, topEnd = 48f, bottomEnd = 12f, bottomStart = 48f) + val start = ChatPhotoTransition.frame(bubble, tail, open, anchorPull = 0f, progress = 0f) + assertEquals(tail, start.corners) + val mid = ChatPhotoTransition.frame(bubble, tail, open, anchorPull = 0f, progress = 0.5f) + assertEquals(ChatPhotoCorners(24f, 24f, 6f, 24f), mid.corners) + val end = ChatPhotoTransition.frame(bubble, tail, open, anchorPull = 0f, progress = 1f) + assertEquals(ChatPhotoCorners.uniform(0f), end.corners) + } + + @Test + fun `the photo is clipped to the transcript at the bubble and to the screen when open`() { + val transcript = Rect(0f, 300f, 1200f, 2100f) + val screen = Rect(Offset.Zero, container) + val corners = ChatPhotoCorners.uniform(48f) + val start = ChatPhotoTransition.frame(bubble, corners, open, 0f, 0f, transcript, container) + assertEquals(transcript, start.clip) + val end = ChatPhotoTransition.frame(bubble, corners, open, 0f, 1f, transcript, container) + assertEquals(screen, end.clip) + // In flight the photo already passes under the bars, so it is behind them well before it lands. + val mid = ChatPhotoTransition.frame(bubble, corners, open, 0f, 0.5f, transcript, container) + assertEquals(transcript, mid.clip) + val leaving = ChatPhotoTransition.frame(bubble, corners, open, 0f, 0.9f, transcript, container) + assertEquals(150f, leaving.clip!!.top, absoluteTolerance = 0.5f) + assertEquals(2250f, leaving.clip!!.bottom, absoluteTolerance = 0.5f) + } + + @Test + fun `the transcript takes the photo over in the last stretch before the bubble`() { + val transcript = Rect(0f, 300f, 1200f, 2100f) + val corners = ChatPhotoCorners.uniform(48f) + fun handoff(p: Float) = ChatPhotoTransition.frame(bubble, corners, open, 0f, p, transcript, container).handoff + assertEquals(1f, handoff(0f)) + assertEquals(0.5f, handoff(0.1f), absoluteTolerance = 0.001f) + assertEquals(0f, handoff(0.2f)) + assertEquals(0f, handoff(0.6f)) + assertEquals(0f, handoff(1f)) + } + + @Test + fun `nothing is handed to the transcript without one or without a bubble`() { + val corners = ChatPhotoCorners.uniform(48f) + assertEquals(0f, ChatPhotoTransition.frame(bubble, corners, open, 0f, 0f).handoff) + val transcript = Rect(0f, 300f, 1200f, 2100f) + assertEquals(0f, ChatPhotoTransition.frame(null, corners, open, 0f, 0f, transcript, container).handoff) + } + + @Test + fun `without a transcript or a bubble nothing is clipped`() { + val corners = ChatPhotoCorners.uniform(48f) + assertEquals(null, ChatPhotoTransition.frame(bubble, corners, open, 0f, 0f).clip) + val transcript = Rect(0f, 300f, 1200f, 2100f) + assertEquals(null, ChatPhotoTransition.frame(null, corners, open, 0f, 0f, transcript, container).clip) + } + @Test fun `progress outside zero to one is clamped`() { - assertEquals(bubble, ChatPhotoTransition.frame(bubble, 0f, open, 0f, -0.2f).rect) - assertEquals(open, ChatPhotoTransition.frame(bubble, 0f, open, 0f, 1.3f).rect) + assertEquals(bubble, ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(0f), open, 0f, -0.2f).rect) + assertEquals(open, ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(0f), open, 0f, 1.3f).rect) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt index ec5056c39..2821cb3ed 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt @@ -3,7 +3,10 @@ package com.flipcash.shared.chat.ui.media import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.performTouchInput import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.BlobMetadata @@ -16,6 +19,8 @@ import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.media.ChatPhotoUnavailable import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.PhotoMessageContext import com.flipcash.shared.chat.ui.BubblePosition import com.flipcash.shared.chat.ui.ContentBubble @@ -57,13 +62,14 @@ class MediaMessageBubbleTest { caption = MessageContent.Text("sunset"), ) - private fun item(previewing: Boolean) = ChatListItem.ContentBubble( + private fun item(previewing: Boolean, quote: ChatQuote? = null) = ChatListItem.ContentBubble( messageId = 7, contentIndex = 0, - content = photo, + content = if (quote != null) MessageContent.Reply(repliedMessageId = quote.messageId, content = listOf(photo)) else photo, isFromSelf = false, timestamp = Instant.fromEpochSeconds(1_000), photo = PhotoMessageContext(chatId, sealed = false, redacted = false, previewing = previewing), + quote = quote, ) @Test @@ -76,6 +82,45 @@ class MediaMessageBubbleTest { composeTestRule.onNodeWithText("sunset").assertIsDisplayed() } + @Test + fun `double tapping a photo presents the reaction strip`() { + var doubleTaps = 0 + composeTestRule.setContent { + DesignSystem { + ContentBubble( + item = item(previewing = false), + position = BubblePosition.Solo, + onLongClick = {}, + onDoubleClick = { doubleTaps++ }, + ) + } + } + + composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, doubleTaps) + } + + @Test + fun `double tapping a photo with nothing else to do still presents the reaction strip`() { + var doubleTaps = 0 + composeTestRule.setContent { + DesignSystem { + ContentBubble( + item = item(previewing = false), + position = BubblePosition.Solo, + onDoubleClick = { doubleTaps++ }, + ) + } + } + + composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, doubleTaps) + } + @Test fun `a photo only opens when it is showing, delivered and loaded`() { assertTrue(canOpenPhoto(blurhashOnly = false, failed = false, unavailable = false)) @@ -127,4 +172,25 @@ class MediaMessageBubbleTest { assertEquals(null, message.photoContext(chatId, previewing = false)) } + + @Test + fun `a long quoted message sits inside the photo it replies with`() { + val quote = ChatQuote( + messageId = 3, + authorName = "Someone with a fairly long display name", + snippet = ChatQuoteSnippet.Text("word ".repeat(80)), + accent = null, + nameAccent = null, + ) + composeTestRule.setContent { + DesignSystem { ContentBubble(item = item(previewing = true, quote = quote), position = BubblePosition.Solo) } + } + + val photo = composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + val chip = composeTestRule.onNodeWithTag(PHOTO_QUOTE_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + + assertTrue(chip.left >= photo.left && chip.right <= photo.right, "chip $chip spills past photo $photo horizontally") + assertTrue(chip.top >= photo.top && chip.bottom <= photo.bottom, "chip $chip sits outside photo $photo vertically") + assertTrue(chip.right - chip.left < photo.right - photo.left, "chip $chip fills the whole photo $photo") + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt new file mode 100644 index 000000000..3aed8f935 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.ui.BubbleCorners +import kotlin.test.Test +import kotlin.test.assertEquals + +class PhotoQuoteCornersTest { + + @Test + fun `chip on a rounded photo corner runs parallel to it`() { + val corners = photoQuoteCorners(photoTopStart = 20.dp, large = 20.dp, small = 4.dp, inset = 8.dp) + + assertEquals(BubbleCorners(12.dp, 12.dp, 12.dp, 12.dp), corners) + } + + @Test + fun `chip on a flattened photo corner floors at the small radius`() { + // A photo grouped under an earlier message from the same sender: its top-start corner is + // the flattened one, and the chip's nested corner can't go below that. + val corners = photoQuoteCorners(photoTopStart = 4.dp, large = 20.dp, small = 4.dp, inset = 8.dp) + + assertEquals(BubbleCorners(4.dp, 12.dp, 12.dp, 12.dp), corners) + } +}