Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.getcode.opencode.compose.LocalExchange
Expand Down Expand Up @@ -142,14 +145,47 @@ fun ChatQuotePanel(
}
}

/** Every measurement the panel makes, carried over from iOS. */
/**
* The panel's measurements. Its typography and its accent come from iOS; its shape and its padding
* come from the bubble it sits in, because those are what read as wrong against the bubble's own
* edge rather than against the iOS panel. The rest are iOS's, and have no token to come from.
*/
private object QuotePanelDefaults {
val shape = RoundedCornerShape(8.dp)
val gap = 8.dp
val trailingPadding = 8.dp
val verticalPadding = 6.dp
/**
* Concentric with the bubble: an inner corner whose arc is the outer one less the gap between
* them holds that gap constant all the way round the turn. That only means anything because
* [BubbleDefaults.surroundInset] is the same gap on all three sides the panel touches — a panel
* inset by one number down the sides and another at the top has no single arc to parallel.
*
* Floored at the bubble's own flattened corner. iOS takes the arithmetic straight, but its 3pt
* result is a continuous curve; the same number as a circular arc draws a hard rectangle inside
* a rounded one, which is the thing being fixed rather than a tighter version of it.
*/
val shape: Shape
@Composable get() = RoundedCornerShape(
max(
BubbleDefaults.cornerLarge - BubbleDefaults.surroundInset,
BubbleDefaults.cornerSmall,
)
)

/**
* One step in from the bubble's own padding on each axis. Matching the bubble exactly is what
* makes the panel read as nested, but at the panel's size it also leaves the two short lines
* swimming — a citation is glanced at, so it wants to be denser than the body it sits above.
* A step down the same grid keeps the relationship without the slack.
*/
val gap: Dp
@Composable get() = CodeTheme.dimens.staticGrid.x2
val trailingPadding: Dp
@Composable get() = CodeTheme.dimens.staticGrid.x2
val verticalPadding: Dp
@Composable get() = CodeTheme.dimens.staticGrid.x1

val cashGap: Dp
@Composable get() = CodeTheme.dimens.staticGrid.x1

val nameGap = 1.dp
val cashGap = 5.dp
val accentWidth = 3.dp
val flagSize = 14.dp
const val groundAlpha = 0.14f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
Expand All @@ -58,13 +59,16 @@ import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.LocalChatActionHandler
import com.flipcash.shared.chat.models.SeparatorConfig
import com.getcode.opencode.compose.ExchangeStub
import com.getcode.opencode.compose.LocalExchange
import com.getcode.opencode.model.financial.Fiat
import com.getcode.theme.CodeTheme
import com.getcode.theme.cornerRadius
import com.getcode.theme.tiny
import com.getcode.ui.components.PriceWithFlag
import com.getcode.ui.core.addIf

Expand Down Expand Up @@ -193,8 +197,43 @@ private const val EDITED_MARKER_SLOT = "edited-marker"
/** How white a bubble goes at the peak of the flash a jump leaves on it. */
private const val ATTENTION_SCRIM_ALPHA = 0.14f

/** Space between a reply's citation and its body. */
private val QUOTE_GAP = 6.dp
/**
* The citation over the body, with the narrower of the two stretched to the width of the wider.
*
* A [Column] would leave a short quote hanging inside a wider reply, and the panel is a filled
* surface, so the slack reads as a notch cut out of the bubble rather than as a quote that happens
* to be short. Filling the width outright is no better: it is the incoming maximum, not the
* sibling's width, so every reply would square off against the widest bubble the transcript allows.
*
* So the body is measured at its own width first and that width becomes the panel's floor. A quote
* longer than the reply keeps its own width and carries it out to the bubble, which is what makes
* this a floor rather than a fixed width.
*/
@Composable
private fun QuotedBody(
gap: Dp,
quote: @Composable () -> Unit,
body: @Composable () -> Unit,
) {
Layout(contents = listOf(quote, body)) { (quoteMeasurables, bodyMeasurables), constraints ->
val gapPx = gap.roundToPx()
val bodyPlaceable = bodyMeasurables.first().measure(
constraints.copy(minWidth = 0, minHeight = 0),
)
val quotePlaceable = quoteMeasurables.first().measure(
constraints.copy(
minWidth = bodyPlaceable.width.coerceAtMost(constraints.maxWidth),
minHeight = 0,
),
)
val width = maxOf(quotePlaceable.width, bodyPlaceable.width)
val height = quotePlaceable.height + gapPx + bodyPlaceable.height
layout(width, height) {
quotePlaceable.place(0, 0)
bodyPlaceable.place(0, quotePlaceable.height + gapPx)
}
}
}

@Composable
private fun TextBubble(
Expand All @@ -210,7 +249,19 @@ private fun TextBubble(
onQuoteLongClick: (() -> Unit)? = null,
attention: () -> Float = { 0f },
) {
Bubble(isFromSelf, position, maxWidth, modifier, attention = attention) {
// A reply hands the bubble the narrower surround, so the citation clears the body's own inset
// on both sides; the body then puts the difference back and keeps the inset it has without a
// quote. A bubble with no quote never widens, because the two are equal there.
val surround = if (quote != null) BubbleDefaults.surroundInset else BubbleDefaults.paddingHorizontal
val bodyInset = BubbleDefaults.paddingHorizontal - surround
Bubble(
isFromSelf,
position,
maxWidth,
modifier,
horizontalPadding = surround,
attention = attention,
) {
val linkStyle = SpanStyle(
color = CodeTheme.colors.textMain,
textDecoration = TextDecoration.Underline,
Expand Down Expand Up @@ -274,32 +325,41 @@ private fun TextBubble(
// selection handle inside the bubble would consume it before the row ever sees it. Copying
// a message is the selection bar's Copy action instead — the same trade WhatsApp makes.
// The citation sits inside the bubble, above the body, so the two move together and the
// reply reads as one message rather than as a quote with a message under it. The panel
// wraps its content rather than filling the bubble: a one-word reply to a long message
// should not stretch to the full bubble width.
Column(verticalArrangement = Arrangement.spacedBy(QUOTE_GAP)) {
if (quote != null) {
ChatQuotePanel(
quote = quote,
onClick = onQuoteClick,
onLongClick = onQuoteLongClick,
// 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("bubble_reply_quote"),
)
}

// reply reads as one message rather than as a quote with a message under it.
val bodyText = @Composable {
Text(
modifier = Modifier.padding(horizontal = bodyInset),
text = laidOut,
inlineContent = inlineContent,
style = bodyStyle,
color = bodyColor,
)
}

if (quote == null) {
bodyText()
} else {
QuotedBody(
gap = BubbleDefaults.surroundInset,
quote = {
ChatQuotePanel(
quote = quote,
onClick = onQuoteClick,
onLongClick = onQuoteLongClick,
// 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("bubble_reply_quote"),
)
},
body = bodyText,
)
}

if (isEdited) {
Text(
modifier = Modifier.align(Alignment.BottomEnd),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = bodyInset),
text = markerLabel,
style = markerStyle,
color = CodeTheme.colors.textSecondary,
Expand Down Expand Up @@ -416,6 +476,39 @@ private fun CashBubble(
}
}

/**
* The bubble's own geometry. Named rather than inlined because the citation panel inside a reply
* has to be laid out against it: a panel that picks its own corner radius and its own padding stops
* matching the bubble the moment either of these moves.
*/
internal object BubbleDefaults {
/** The radius of a corner on the outside of a same-sender run. */
val cornerLarge: Dp
@Composable get() = CodeTheme.shapes.medium.cornerRadius()

/** The flattened radius of a corner facing the rest of the run. */
val cornerSmall: Dp
@Composable get() = CodeTheme.shapes.tiny.cornerRadius()

val paddingHorizontal: Dp
@Composable get() = CodeTheme.dimens.staticGrid.x3

val paddingVertical: Dp
@Composable get() = CodeTheme.dimens.staticGrid.x2

/**
* The gap between the citation panel and the bubble, the same on the top, leading and trailing
* sides and again between the panel and the body beneath it. One number rather than three, so
* the panel's corner has a single surround to be concentric with and the quote, the body and
* the bubble's edges sit on one rhythm.
*
* The bubble's vertical margin, which is the narrower of its two insets: the body keeps the
* wider horizontal one, so the panel reaches past the text on both sides the way it does on iOS.
*/
val surroundInset: Dp
@Composable get() = paddingVertical
}

@Composable
private fun Bubble(
isFromSelf: Boolean,
Expand All @@ -425,6 +518,7 @@ private fun Bubble(
minWidth: Dp = 0.dp,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
horizontalPadding: Dp = BubbleDefaults.paddingHorizontal,
attention: () -> Float = { 0f },
content: @Composable BoxScope.() -> Unit,
) {
Expand Down Expand Up @@ -464,16 +558,19 @@ private fun Bubble(
onClick = { onClick?.invoke() },
)
}
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(
horizontal = horizontalPadding,
vertical = BubbleDefaults.paddingVertical,
),
) {
content()
}
}

@Composable
fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape {
val l = 12.dp
val s = 4.dp
val l = BubbleDefaults.cornerLarge
val s = BubbleDefaults.cornerSmall

val cornerSpec = spring<Dp>(dampingRatio = 0.68f, stiffness = 500f)

Expand Down Expand Up @@ -776,4 +873,51 @@ private fun Preview_Conversation() {
}
}

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_TextBubble_Reply() {
TextBubble(
text = "on my way",
isFromSelf = true,
position = BubblePosition.Solo,
maxWidth = 300.dp,
quote = ChatQuote(
messageId = 1L,
authorName = "Alice",
snippet = ChatQuoteSnippet.Text("are you still coming tonight?"),
accent = Color(0xFF5B8DEF),
nameAccent = Color(0xFF8FB4F5),
),
)
}

/**
* The citation against the bubble corners it is set into: a run's middle bubble flattens the
* corners the panel sits nearest, which is where an inner radius of its own would show.
*/
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_GroupedBubbles_Reply() {
val quote = ChatQuote(
messageId = 1L,
authorName = "Alice",
snippet = ChatQuoteSnippet.Text("are you still coming tonight?"),
accent = Color(0xFF5B8DEF),
nameAccent = Color(0xFF8FB4F5),
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.End) {
TextBubble("First message", true, BubblePosition.First, 300.dp)
TextBubble(
text = "on my way",
isFromSelf = true,
position = BubblePosition.Middle,
maxWidth = 300.dp,
quote = quote,
)
TextBubble("Third message", true, BubblePosition.Last, 300.dp)
}
}

// endregion
14 changes: 14 additions & 0 deletions ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ val Shapes.xxl: CornerBasedShape
@Composable
fun Shapes.receipt(step: Dp = CodeTheme.dimens.grid.x2) = TriangleCutShape(step)

/**
* This shape's corner radius as a [Dp], for a caller that has to recombine or animate the corners
* itself rather than hand the shape straight to a modifier.
*
* Reads [topStart]: a shape whose corners differ has no single radius to report, so this is for the
* uniform tokens in [shapes] and the extensions beside them.
*/
@Composable
fun CornerBasedShape.cornerRadius(): Dp {
val density = LocalDensity.current
val ref = Size(10_000f, 10_000f)
return with(density) { topStart.toPx(ref, this).toDp() }
}

/**
* Returns a new [RoundedCornerShape] whose corners are concentric with this shape,
* inset by [inset]. Useful for nested rounded rectangles (e.g. a thumb inside a track)
Expand Down
Loading