From c8cbb84baa3026a6f5974544efb9fd468e2f3118 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 16 Sep 2026 20:58:42 +0530 Subject: [PATCH 01/17] input field --- .../compose/jetchat/components/MeshUtils.kt | 215 +++++++++ .../compose/jetchat/conversation/UserInput.kt | 452 +++++++++++------- Jetchat/app/src/main/res/drawable/ic_add.xml | 9 + Jetchat/app/src/main/res/drawable/ic_send.xml | 4 +- .../app/src/main/res/drawable/ic_spark.xml | 9 + Jetchat/app/src/main/res/values/strings.xml | 2 +- 6 files changed, 517 insertions(+), 174 deletions(-) create mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt create mode 100644 Jetchat/app/src/main/res/drawable/ic_add.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_spark.xml diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt new file mode 100644 index 0000000000..95fcfb12c6 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.MeshGradientPainter +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * A gently animated mesh gradient used as the glow behind the chat [UserInput] bar. + * + * The softness comes entirely from the mesh: + * - [MeshGradientPainter.hasBicubicColor] gives smooth (non-linear) colour blending, and + * - the outer ring of vertices is fully transparent (alpha 0x00), so the glow feathers to + * nothing at its edges. + * + * That means no `.blur()` and no linear gradient are needed to reproduce the effect. + */ +@Composable +fun rememberUserInputGlowMeshGradientPainter(): MeshGradientPainter { + val transition = rememberInfiniteTransition(label = "userInputGlowMesh") + val phase1 by transition.animateFloat( + initialValue = 0f, + targetValue = (2 * PI).toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2800, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "glowPhase1", + ) + val phase2 by transition.animateFloat( + initialValue = 0f, + targetValue = (2 * PI).toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 4200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "glowPhase2", + ) + + // Helper for smooth organic orbital displacement per interior vertex. + fun orbit(colIdx: Int, rowIdx: Int, ampX: Float, ampY: Float): Offset { + val angle1 = phase1 + colIdx * 0.85f + rowIdx * 0.65f + val angle2 = phase2 - colIdx * 0.55f + rowIdx * 0.90f + val dx = ampX * (0.72f * cos(angle1) + 0.28f * sin(angle2)) + val dy = ampY * (0.72f * sin(angle1) + 0.28f * cos(angle2)) + return Offset(dx, dy) + } + + return remember(phase1, phase2) { + val uCols = floatArrayOf(0.00f, 0.12f, 0.27f, 0.56f, 0.78f, 1.00f) + val vRows = floatArrayOf(0.00f, 0.20f, 0.33f, 0.43f, 0.64f, 0.85f, 1.00f) + + val gridColors = arrayOf( + // Row 0: transparent royal-blue boundary (top of the glow). + arrayOf( + Color(0x000B57D0), + Color(0x000B57D0), + Color(0x000B57D0), + Color(0x000B57D0), + Color(0x000B57D0), + Color(0x000B57D0), + ), + // Row 1: high royal-blue aura. + arrayOf( + Color(0x00125CCF), + Color(0x3C1F62D8), + Color(0x5C2365D7), + Color(0x66125CCF), + Color(0x451D63D1), + Color(0x001D63D1), + ), + // Row 2: mid-upper sky-cerulean aura. + arrayOf( + Color(0x006B88EE), + Color(0x80829CF2), + Color(0xA86E8DF0), + Color(0xB46488EE), + Color(0x825C86EC), + Color(0x005C86EC), + ), + // Row 3: lavender left, periwinkle centre, cobalt right shoulder. + arrayOf( + Color(0x00B6ABFB), + Color(0xEEB6ABFB), + Color(0xF4A39BFC), + Color(0xF59D98FC), + Color(0xF25C7CF5), + Color(0x18728CF5), + ), + // Row 4: vibrant orchid-lavender left, electric cobalt right (card height). + arrayOf( + Color(0x00C2A7FF), + Color(0xFFC2A7FF), + Color(0xFFC1A6FF), + Color(0xFF687DF9), + Color(0xF04E6EFA), + Color(0x205875FA), + ), + // Row 5: crisp indigo-cobalt shadow along the bottom. + arrayOf( + Color(0x007E8AF4), + Color(0xE27E8AF4), + Color(0xF45A6DFB), + Color(0xF6556AFC), + Color(0xFA3C5CFC), + Color(0x204D6AFB), + ), + // Row 6: transparent cobalt boundary (bottom of the glow). + arrayOf( + Color(0x005A6DFB), + Color(0x005A6DFB), + Color(0x005A6DFB), + Color(0x00556AFC), + Color(0x003C5CFC), + Color(0x003C5CFC), + ), + ) + + MeshGradientPainter( + rows = 6, + columns = 5, + hasBicubicColor = true, + ) { + for (r in 0..6) { + for (c in 0..5) { + val baseU = uCols[c] + val baseV = vRows[r] + val offset = if (r in 1..5 && c in 1..4) { + val ampX = when (r) { + 1, 2 -> 0.048f + 3 -> 0.042f + 4 -> 0.028f + else -> 0.036f + } + val ampY = when (r) { + 1, 2 -> 0.028f + 3 -> 0.020f + 4 -> 0.022f + else -> 0.016f + } + val d = orbit(c, r, ampX, ampY) + Offset( + x = (baseU + d.x).coerceIn(0.04f, 0.96f), + y = (baseV + d.y).coerceIn(0.05f, 0.95f), + ) + } else { + Offset(baseU, baseV) + } + setVertex(r, c, offset, gridColors[r][c]) + } + } + } + } +} + +/** + * A small static mesh gradient for the Gemini spark button: a pink → purple → blue diagonal + * built from a 2x2-cell mesh (3x3 vertices) with bicubic colour blending. This reproduces the + * Figma diagonal fill without a linear gradient, keeping the screen mesh-only. + */ +@Composable +fun rememberUserInputSparkMeshGradientPainter(): MeshGradientPainter { + val pink = Color(0xFFF96BD6) + val pinkPurple = Color(0xFFC072EA) + val purple = Color(0xFF9378FF) + val purpleBlue = Color(0xFF585CFF) + val blue = Color(0xFF1E40FF) + + return remember { + val positions = floatArrayOf(0f, 0.5f, 1f) + val colors = arrayOf( + arrayOf(pink, pinkPurple, purple), + arrayOf(pinkPurple, purple, purpleBlue), + arrayOf(purple, purpleBlue, blue), + ) + MeshGradientPainter( + rows = 2, + columns = 2, + hasBicubicColor = true, + ) { + for (r in 0..2) { + for (c in 0..2) { + setVertex(r, c, Offset(positions[c], positions[r]), colors[r][c]) + } + } + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 699b3835c5..5723ccf3f9 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -20,9 +20,7 @@ import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -35,7 +33,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -45,8 +42,6 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -54,7 +49,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -62,7 +56,6 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -81,10 +74,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.paint import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusTarget @@ -92,7 +85,9 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -106,10 +101,23 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.heightIn +import androidx.compose.ui.Alignment +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R +import com.example.compose.jetchat.components.rememberUserInputGlowMeshGradientPainter +import com.example.compose.jetchat.components.rememberUserInputSparkMeshGradientPainter +import com.example.compose.jetchat.theme.KarlaFontFamily import kotlin.math.absoluteValue import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -130,7 +138,7 @@ enum class EmojiStickerSelector { STICKER, } -@Preview +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 412, heightDp = 320) @Composable fun UserInputPreview() { UserInput(onMessageSent = {}) @@ -182,53 +190,139 @@ fun UserInput( // Used to decide if the keyboard should be shown var textFieldFocusState by remember { mutableStateOf(false) } - Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) { - Column(modifier = modifier) { - AnimatedVisibility( - visible = attachedVideoUri != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - attachedVideoUri?.let { videoUri -> - AttachedVideoPreview( - videoUri = videoUri, - onRemove = { attachedVideoUri = null }, - ) - } + val glowMeshPainter = rememberUserInputGlowMeshGradientPainter() + + val surfaceColor = Color(0xFFEAE9FC) + val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null + // Gemini is "active" only when the message mentions @gemini. Drives both the glow and + // the spark button's gradient fill. + val isGeminiActive = textState.text.contains("@gemini", ignoreCase = true) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 4.dp, bottom = 8.dp, top = 6.dp), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + // Animated mesh-gradient glow behind the card. Only shown when the message + // mentions @gemini. The soft feather is produced entirely by the mesh (bicubic + // colour + fully transparent boundary vertices), so there is no blur and no + // linear gradient. The layout() lets the glow bleed above/below/beside the card + // without adding size to the parent Column. + if (isGeminiActive) { + Box( + modifier = Modifier + .matchParentSize() + .layout { measurable, constraints -> + val topExpand = 185.dp.roundToPx() + val bottomExpand = 68.dp.roundToPx() + val horizontalExpand = 76.dp.roundToPx() + val expandedWidth = constraints.maxWidth + horizontalExpand * 2 + val expandedHeight = constraints.maxHeight + topExpand + bottomExpand + val placeable = measurable.measure( + Constraints.fixed(expandedWidth, expandedHeight), + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(-horizontalExpand, -topExpand) + } + } + .paint(glowMeshPainter, contentScale = ContentScale.FillBounds), + ) } - UserInputText( - textFieldValue = textState, - onTextChanged = { textState = it }, - // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, - // Close extended selector if text field receives focus - onTextFieldFocused = { focused -> - if (focused) { - currentInputSelector = InputSelector.NONE - resetScroll() + Surface( + shape = RoundedCornerShape( + topStart = 48.dp, + topEnd = 48.dp, + bottomEnd = 48.dp, + bottomStart = 48.dp, + ), + color = surfaceColor, + shadowElevation = 0.dp, + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .heightIn(min = 160.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 160.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + AnimatedVisibility( + visible = attachedVideoUri != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + attachedVideoUri?.let { videoUri -> + AttachedVideoPreview( + videoUri = videoUri, + onRemove = { attachedVideoUri = null }, + ) + } + } + + UserInputText( + textFieldValue = textState, + onTextChanged = { textState = it }, + // Only show the keyboard if there's no input selector and text field has focus + keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, + // Close extended selector if text field receives focus + onTextFieldFocused = { focused -> + if (focused) { + currentInputSelector = InputSelector.NONE + resetScroll() + } + textFieldFocusState = focused + }, + onMessageSent = { sendMessage() }, + focusState = textFieldFocusState, + ) } - textFieldFocusState = focused - }, - onMessageSent = { sendMessage() }, - focusState = textFieldFocusState, - ) - UserInputSelector( - onSelectorChange = { currentInputSelector = it }, - sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null, - onMessageSent = sendMessage, - currentInputSelector = currentInputSelector, - onVideoClick = { - currentInputSelector = InputSelector.NONE - videoPickerLauncher.launch("video/*") - }, - ) - SelectorExpanded( - onCloseRequested = dismissKeyboard, - onTextAdded = { textState = textState.addText(it) }, - currentSelector = currentInputSelector, - ) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + UserInputSelector( + onSelectorChange = { currentInputSelector = it }, + currentInputSelector = currentInputSelector, + geminiActive = isGeminiActive, + onVideoClick = { + currentInputSelector = InputSelector.NONE + videoPickerLauncher.launch("video/*") + }, + onAddClick = { currentInputSelector = InputSelector.MAP }, + ) + + IconButton( + onClick = sendMessage, + modifier = Modifier.clickable(enabled = sendMessageEnabled, onClick = sendMessage).size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = null, + tint = Color(0xFF444746).copy(alpha = if (sendMessageEnabled) 0.85f else 0.54f), + modifier = Modifier.size(24.dp), + ) + } + } + + } + } } + + SelectorExpanded( + onCloseRequested = dismissKeyboard, + onTextAdded = { textState = textState.addText(it) }, + currentSelector = currentInputSelector, + ) } } @@ -340,79 +434,99 @@ fun FunctionalityNotAvailablePanel() { @Composable private fun UserInputSelector( onSelectorChange: (InputSelector) -> Unit, - sendMessageEnabled: Boolean, - onMessageSent: () -> Unit, currentInputSelector: InputSelector, + geminiActive: Boolean, modifier: Modifier = Modifier, onVideoClick: () -> Unit = {}, + onAddClick: () -> Unit = {}, ) { + val iconTint = Color(0xFF3E41F4) + val sparkMeshPainter = rememberUserInputSparkMeshGradientPainter() + Row( - modifier = modifier - .height(72.dp) - .wrapContentHeight() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - InputSelectorButton( + // Emoji + IconButton( onClick = { onSelectorChange(InputSelector.EMOJI) }, - icon = painterResource(id = R.drawable.ic_mood), - selected = currentInputSelector == InputSelector.EMOJI, - description = stringResource(id = R.string.emoji_selector_bt_desc), - ) - InputSelectorButton( - onClick = { onSelectorChange(InputSelector.DM) }, - icon = painterResource(id = R.drawable.ic_alternate_email), - selected = currentInputSelector == InputSelector.DM, - description = stringResource(id = R.string.dm_desc), - ) - InputSelectorButton( - onClick = { onSelectorChange(InputSelector.PICTURE) }, - icon = painterResource(id = R.drawable.ic_insert_photo), - selected = currentInputSelector == InputSelector.PICTURE, - description = stringResource(id = R.string.attach_photo_desc), - ) - InputSelectorButton( - onClick = { onSelectorChange(InputSelector.MAP) }, - icon = painterResource(id = R.drawable.ic_place), - selected = currentInputSelector == InputSelector.MAP, - description = stringResource(id = R.string.map_selector_desc), - ) - InputSelectorButton( - onClick = onVideoClick, - icon = painterResource(id = R.drawable.ic_duo), - selected = false, - description = stringResource(id = R.string.videochat_desc), - ) + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_mood), + contentDescription = stringResource(id = R.string.emoji_selector_bt_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } - val border = if (!sendMessageEnabled) { - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + // Photo + IconButton( + onClick = { onSelectorChange(InputSelector.PICTURE) }, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_insert_photo), + contentDescription = stringResource(id = R.string.attach_photo_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), ) - } else { - null } - Spacer(modifier = Modifier.weight(1f)) - val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + // Video / Duo + IconButton( + onClick = onVideoClick, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_duo), + contentDescription = stringResource(id = R.string.videochat_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } - val buttonColors = ButtonDefaults.buttonColors( - disabledContainerColor = Color.Transparent, - disabledContentColor = disabledContentColor, - ) + // Gemini spark button. Only active (mesh-gradient circle + white icon) when the + // message mentions @gemini; otherwise it's a plain blue-tinted icon like the others. + if (geminiActive) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .paint(sparkMeshPainter, contentScale = ContentScale.FillBounds) + .clickable { onSelectorChange(InputSelector.DM) }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_spark), + contentDescription = stringResource(id = R.string.dm_desc), + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } else { + IconButton( + onClick = { onSelectorChange(InputSelector.DM) }, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_spark), + contentDescription = stringResource(id = R.string.dm_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } + } - // Send button - Button( - modifier = Modifier.height(36.dp), - enabled = sendMessageEnabled, - onClick = onMessageSent, - colors = buttonColors, - border = border, - contentPadding = PaddingValues(0.dp), + // Add / attachment + IconButton( + onClick = onAddClick, + modifier = Modifier.size(48.dp), ) { - Text( - stringResource(id = R.string.send), - modifier = Modifier.padding(horizontal = 16.dp), + Icon( + painter = painterResource(id = R.drawable.ic_add), + contentDescription = stringResource(id = R.string.map_selector_desc), + tint = Color(0xFF0B57D0), + modifier = Modifier.size(24.dp), ) } } @@ -462,7 +576,6 @@ private fun NotAvailablePopup(onDismissed: () -> Unit) { val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey -@OptIn(ExperimentalAnimationApi::class) @ExperimentalFoundationApi @Composable private fun UserInputText( @@ -474,58 +587,27 @@ private fun UserInputText( onMessageSent: (String) -> Unit, focusState: Boolean, ) { - val swipeOffset = remember { mutableStateOf(0f) } - var isRecordingMessage by remember { mutableStateOf(false) } val a11ylabel = stringResource(id = R.string.textfield_desc) - Row( + // Figma 'Chat' text area at x=32dp, top=20dp, end=40dp. + Box( modifier = Modifier .fillMaxWidth() - .height(64.dp), - horizontalArrangement = Arrangement.End, + .padding(start = 32.dp, top = 20.dp, end = 40.dp) + .heightIn(min = 64.dp), ) { - AnimatedContent( - targetState = isRecordingMessage, - label = "text-field", - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) { recording -> - Box(Modifier.fillMaxSize()) { - if (recording) { - RecordingIndicator { swipeOffset.value } - } else { - UserInputTextField( - textFieldValue, - onTextChanged, - onTextFieldFocused, - keyboardType, - focusState, - onMessageSent, - Modifier.fillMaxWidth().semantics { - contentDescription = a11ylabel - keyboardShownProperty = keyboardShown - }, - ) - } - } - } - RecordButton( - recording = isRecordingMessage, - swipeOffset = { swipeOffset.value }, - onSwipeOffsetChange = { offset -> swipeOffset.value = offset }, - onStartRecording = { - val consumed = !isRecordingMessage - isRecordingMessage = true - consumed - }, - onFinishRecording = { - // handle end of recording - isRecordingMessage = false - }, - onCancelRecording = { - isRecordingMessage = false - }, - modifier = Modifier.fillMaxHeight(), + UserInputTextField( + textFieldValue, + onTextChanged, + onTextFieldFocused, + keyboardType, + focusState, + onMessageSent, + Modifier + .fillMaxWidth() + .semantics { + contentDescription = a11ylabel + keyboardShownProperty = keyboardShown + }, ) } } @@ -541,12 +623,43 @@ private fun BoxScope.UserInputTextField( modifier: Modifier = Modifier, ) { var lastFocusState by remember { mutableStateOf(false) } + + // When unfocused, draw a trailing "|" caret after any typed text (matches the Figma mock). + val unfocusedCaret = remember(focusState) { + if (!focusState) { + VisualTransformation { annotated -> + if (annotated.text.isNotEmpty()) { + TransformedText( + text = AnnotatedString(annotated.text + "|"), + offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + override fun transformedToOriginal(offset: Int): Int = + offset.coerceAtMost(annotated.text.length) + }, + ) + } else { + TransformedText(annotated, OffsetMapping.Identity) + } + } + } else { + VisualTransformation.None + } + } + + // Uses the app's existing Karla font family (SemiBold resolves to the bundled Karla Bold). + val textStyle = TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 28.sp, + color = Color(0xFF001CBA), + ) + BasicTextField( value = textFieldValue, onValueChange = { onTextChanged(it) }, modifier = modifier - .padding(start = 32.dp) - .align(Alignment.CenterStart) + .align(Alignment.TopStart) .onFocusChanged { state -> if (lastFocusState != state.isFocused) { onTextFieldFocused(state.isFocused) @@ -560,20 +673,17 @@ private fun BoxScope.UserInputTextField( keyboardActions = KeyboardActions { if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text) }, - maxLines = 1, - cursorBrush = SolidColor(LocalContentColor.current), - textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), + maxLines = 4, + visualTransformation = unfocusedCaret, + cursorBrush = SolidColor(Color(0xFF001CBA)), + textStyle = textStyle, ) - val disableContentColor = - MaterialTheme.colorScheme.onSurfaceVariant if (textFieldValue.text.isEmpty() && !focusState) { Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 32.dp), + modifier = Modifier.align(Alignment.TopStart), text = stringResource(R.string.textfield_hint), - style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor), + style = textStyle.copy(color = Color(0xFF49454F)), ) } } diff --git a/Jetchat/app/src/main/res/drawable/ic_add.xml b/Jetchat/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000000..4dd75c39ff --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_send.xml b/Jetchat/app/src/main/res/drawable/ic_send.xml index 03559f0deb..7a9580c2d5 100644 --- a/Jetchat/app/src/main/res/drawable/ic_send.xml +++ b/Jetchat/app/src/main/res/drawable/ic_send.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="#FF444746" + android:pathData="M19.675,13.225L4.425,19.675C3.975,19.875 3.55,19.8417 3.15,19.575C2.75,19.3083 2.55,18.9333 2.55,18.45V5.55C2.55,5.0667 2.75,4.6917 3.15,4.425C3.55,4.1583 3.975,4.125 4.425,4.325L19.675,10.775C20.2083,11.025 20.475,11.4333 20.475,12C20.475,12.5667 20.2083,12.975 19.675,13.225ZM5,16.8L16.325,12L5,7.2V10.3L11.2,12L5,13.7V16.8Z"/> diff --git a/Jetchat/app/src/main/res/drawable/ic_spark.xml b/Jetchat/app/src/main/res/drawable/ic_spark.xml new file mode 100644 index 0000000000..c5aba5a908 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_spark.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 68aa3e2ada..b435de88ff 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -34,7 +34,7 @@ me 8:30 PM %d members - Message #composers + Send a message ◀ Swipe to cancel Emojis Stickers From 27ef0137cf2ba18e8b0b3851315f42c36078b312 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 16 Sep 2026 21:50:21 +0530 Subject: [PATCH 02/17] shadows behind input field --- .../jetchat/conversation/Conversation.kt | 4 +- .../compose/jetchat/conversation/UserInput.kt | 43 +++++++++++++------ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 91593e0d29..d5a2adb65f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -81,6 +81,7 @@ import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -179,7 +180,7 @@ fun ConversationContent( var activeVideoUri by rememberSaveable { mutableStateOf(null) } - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = modifier.fillMaxSize().background(color = Color(0xFFEAE9FC))) { Scaffold( topBar = { ChannelNameBar( @@ -194,6 +195,7 @@ fun ConversationContent( .contentWindowInsets .exclude(WindowInsets.navigationBars) .exclude(WindowInsets.ime), + containerColor = Color.Transparent, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Column( diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 3e5fe6b4b1..0206776791 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.paint +import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusTarget @@ -231,18 +232,30 @@ fun UserInput( ) } + val cardShape = RoundedCornerShape(48.dp) Surface( - shape = RoundedCornerShape( - topStart = 48.dp, - topEnd = 48.dp, - bottomEnd = 48.dp, - bottomStart = 48.dp, - ), + shape = cardShape, color = surfaceColor, - shadowElevation = 0.dp, + // Default soft shadow when Gemini is idle; the glow replaces it when active. + shadowElevation = if (isGeminiActive) 4.dp else 8.dp, modifier = Modifier .fillMaxWidth() .padding(end = 4.dp) + // Default blue-tinted shadow when Gemini is idle; the glow replaces it + // when active. Tinted shadows render on API 28+ (black on older versions). + .then( + if (isGeminiActive) { + Modifier + } else { + Modifier.shadow( + elevation = 16.dp, + shape = cardShape, + clip = false, + ambientColor = Color(0xFF3E41F4), + spotColor = Color(0xFF3E41F4), + ) + }, + ) .heightIn(min = 160.dp), ) { Column( @@ -647,13 +660,15 @@ private fun BoxScope.UserInputTextField( } } - // Uses the app's existing Karla font family (SemiBold resolves to the bundled Karla Bold). + // Figma spec: Karla, 24sp / 24 line-height, weight 341, colour #000965. + // The app's KarlaFontFamily ships Regular (400) + Bold (700); weight 341 resolves + // to the nearest — Karla Regular — which matches the light look in the mock. val textStyle = TextStyle( fontFamily = KarlaFontFamily, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight(341), fontSize = 24.sp, - lineHeight = 28.sp, - color = Color(0xFF001CBA), + lineHeight = 24.sp, + color = Color(0xFF000965), ) BasicTextField( @@ -676,15 +691,15 @@ private fun BoxScope.UserInputTextField( }, maxLines = 4, visualTransformation = unfocusedCaret, - cursorBrush = SolidColor(Color(0xFF001CBA)), - textStyle = textStyle, + cursorBrush = SolidColor(Color(0xFF000965)), + textStyle = MaterialTheme.typography.headlineSmall, ) if (textFieldValue.text.isEmpty() && !focusState) { Text( modifier = Modifier.align(Alignment.TopStart), text = stringResource(R.string.textfield_hint), - style = textStyle.copy(color = Color(0xFF49454F)), + style = MaterialTheme.typography.headlineSmall, ) } } From b422ff0e08bdbc411b0e43d85045ed4a99e22a65 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 16 Sep 2026 22:06:27 +0530 Subject: [PATCH 03/17] shadows behind input field --- .../compose/jetchat/conversation/Conversation.kt | 2 +- .../compose/jetchat/conversation/UserInput.kt | 13 +------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index d5a2adb65f..01b65b9a56 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -278,7 +278,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + tint = Color(0xFFEAE9FC).copy(alpha = 0.5f), elevation = 0.dp, radius = 12.dp, ), diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 0206776791..1857514496 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -660,17 +660,6 @@ private fun BoxScope.UserInputTextField( } } - // Figma spec: Karla, 24sp / 24 line-height, weight 341, colour #000965. - // The app's KarlaFontFamily ships Regular (400) + Bold (700); weight 341 resolves - // to the nearest — Karla Regular — which matches the light look in the mock. - val textStyle = TextStyle( - fontFamily = KarlaFontFamily, - fontWeight = FontWeight(341), - fontSize = 24.sp, - lineHeight = 24.sp, - color = Color(0xFF000965), - ) - BasicTextField( value = textFieldValue, onValueChange = { onTextChanged(it) }, @@ -692,7 +681,7 @@ private fun BoxScope.UserInputTextField( maxLines = 4, visualTransformation = unfocusedCaret, cursorBrush = SolidColor(Color(0xFF000965)), - textStyle = MaterialTheme.typography.headlineSmall, + textStyle = MaterialTheme.typography.headlineSmall.copy(color = Color(0xFF001CBA)), ) if (textFieldValue.text.isEmpty() && !focusState) { From 7a28ae72409d63908b5d62c3dec66b0ec97a778c Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 17 Sep 2026 09:07:09 +0530 Subject: [PATCH 04/17] fade in wave effect --- .../compose/jetchat/conversation/UserInput.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 1857514496..1588619b38 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -24,6 +24,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween @@ -206,12 +207,17 @@ fun UserInput( .padding(start = 8.dp, end = 4.dp, bottom = 8.dp, top = 6.dp), ) { Box(modifier = Modifier.fillMaxWidth()) { - // Animated mesh-gradient glow behind the card. Only shown when the message - // mentions @gemini. The soft feather is produced entirely by the mesh (bicubic + // Animated mesh-gradient glow behind the card. Fades in/out as the message + // gains/loses @gemini. The soft feather is produced entirely by the mesh (bicubic // colour + fully transparent boundary vertices), so there is no blur and no // linear gradient. The layout() lets the glow bleed above/below/beside the card // without adding size to the parent Column. - if (isGeminiActive) { + val glowAlpha by animateFloatAsState( + targetValue = if (isGeminiActive) 1f else 0f, + animationSpec = tween(durationMillis = 600), + label = "glowFade", + ) + if (glowAlpha > 0f) { Box( modifier = Modifier .matchParentSize() @@ -228,6 +234,7 @@ fun UserInput( placeable.place(-horizontalExpand, -topExpand) } } + .graphicsLayer { alpha = glowAlpha } .paint(glowMeshPainter, contentScale = ContentScale.FillBounds), ) } From a9896ea969f625ffc66e979a169b9cdc886c47fc Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 17 Sep 2026 09:24:32 +0530 Subject: [PATCH 05/17] messages alignment --- .../jetchat/conversation/Conversation.kt | 239 ++++++++++++------ 1 file changed, 162 insertions(+), 77 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 01b65b9a56..f0333ec7ba 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -182,6 +182,7 @@ fun ConversationContent( Box(modifier = modifier.fillMaxSize().background(color = Color(0xFFEAE9FC))) { Scaffold( + containerColor = Color(0xFFEAE9FC), topBar = { ChannelNameBar( channelName = uiState.channelName, @@ -195,7 +196,7 @@ fun ConversationContent( .contentWindowInsets .exclude(WindowInsets.navigationBars) .exclude(WindowInsets.ime), - containerColor = Color.Transparent, +// containerColor = Color.Transparent, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Column( @@ -290,12 +291,13 @@ fun ChannelNameBar( Text( text = channelName, style = MaterialTheme.typography.titleMedium, + color = Color(0xFF000965) ) // Number of members Text( text = stringResource(R.string.members, channelMembers), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = Color(0xFF444746), ) } }, @@ -414,44 +416,87 @@ fun Message( isLastMessageByAuthor: Boolean, onVideoClick: (String) -> Unit = {}, ) { - val borderColor = if (isUserMe) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.tertiary - } - - val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier - Row(modifier = spaceBetweenAuthors) { - if (isLastMessageByAuthor) { - // Avatar - Image( + val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 12.dp) else Modifier + + if (isUserMe) { + // Self messages: right-aligned bubble (#97A5FF) with avatar on the right (Figma 191:25321, 191:25358) + Row( + modifier = spaceBetweenAuthors + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.End, + verticalAlignment = Alignment.Top, + ) { + AuthorAndTextMessage( + msg = msg, + isUserMe = true, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + authorClicked = onAuthorClick, + onVideoClick = onVideoClick, modifier = Modifier - .clickable(onClick = { onAuthorClick(msg.author) }) - .padding(horizontal = 16.dp) - .size(42.dp) - .border(1.5.dp, borderColor, CircleShape) - .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) - .clip(CircleShape) - .align(Alignment.Top), - painter = painterResource(id = msg.authorImage), - contentScale = ContentScale.Crop, - contentDescription = null, + .weight(1f, fill = false) + .padding(start = 32.dp), ) - } else { - // Space under avatar - Spacer(modifier = Modifier.width(74.dp)) + Spacer(modifier = Modifier.width(12.dp)) + if (isLastMessageByAuthor) { + Image( + modifier = Modifier + .clickable(onClick = { onAuthorClick(msg.author) }) + .size(48.dp) + .border(1.5.dp, Color(0xFF97A5FF), CircleShape) + .clip(CircleShape), + painter = painterResource(id = msg.authorImage), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Spacer(modifier = Modifier.width(48.dp)) + } + } + } else { + // Other user messages: left-aligned avatar + pill badge header + bubble (#D5DAFF) (Figma 191:25301, 191:25334) + Column( + modifier = spaceBetweenAuthors + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + if (isLastMessageByAuthor) { + AuthorNameTimestamp(msg = msg, onAuthorClick = onAuthorClick) + Spacer(modifier = Modifier.height(6.dp)) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + if (isLastMessageByAuthor) { + Image( + modifier = Modifier + .clickable(onClick = { onAuthorClick(msg.author) }) + .size(48.dp) + .border(1.5.dp, Color(0xFF1E40FF).copy(alpha = 0.2f), CircleShape) + .clip(CircleShape), + painter = painterResource(id = msg.authorImage), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Spacer(modifier = Modifier.width(48.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + AuthorAndTextMessage( + msg = msg, + isUserMe = false, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + authorClicked = onAuthorClick, + onVideoClick = onVideoClick, + modifier = Modifier + .weight(1f, fill = false) + .padding(end = 32.dp), + ) + } } - AuthorAndTextMessage( - msg = msg, - isUserMe = isUserMe, - isFirstMessageByAuthor = isFirstMessageByAuthor, - isLastMessageByAuthor = isLastMessageByAuthor, - authorClicked = onAuthorClick, - onVideoClick = onVideoClick, - modifier = Modifier - .padding(end = 16.dp) - .weight(1f), - ) } } @@ -465,10 +510,10 @@ fun AuthorAndTextMessage( modifier: Modifier = Modifier, onVideoClick: (String) -> Unit = {}, ) { - Column(modifier = modifier) { - if (isLastMessageByAuthor) { - AuthorNameTimestamp(msg) - } + Column( + modifier = modifier, + horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start, + ) { ChatItemBubble( message = msg, isUserMe = isUserMe, @@ -486,41 +531,70 @@ fun AuthorAndTextMessage( } @Composable -private fun AuthorNameTimestamp(msg: Message) { - // Combine author and timestamp for a11y. - Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { - Text( - text = msg.author, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .alignBy(LastBaseline) - .paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = msg.timestamp, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.alignBy(LastBaseline), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) +private fun AuthorNameTimestamp( + msg: Message, + onAuthorClick: (String) -> Unit = {}, +) { + // Figma name+time pill badge (id=191:25314, 191:25347) + // Fill: #EAE9FC + 20% #1E40FF (~#D4D9FC), cornerRadius = 24.dp + Surface( + shape = RoundedCornerShape(24.dp), + color = Color(0xFFD4D9FC), + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .clickable { onAuthorClick(msg.author) } + .semantics(mergeDescendants = true) {}, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = msg.author, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF1E40FF), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = msg.timestamp, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF836FB2), + ) + } } } -private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) +// Figma 191:25313 / 191:25346: cr=[24.0, 24.0, 24.0, 4.0] for other users +private val OtherChatBubbleShape = RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomEnd = 24.dp, + bottomStart = 4.dp, +) + +private val SelfChatBubbleShape = RoundedCornerShape( + topStart = 24.dp, + topEnd = 4.dp, + bottomEnd = 24.dp, + bottomStart = 24.dp, +) @Composable fun DayHeader(dayString: String) { Row( modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .height(16.dp), + .padding(vertical = 12.dp, horizontal = 16.dp) + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, ) { DayHeaderLine() Text( text = dayString, modifier = Modifier.padding(horizontal = 16.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ), + color = Color(0xFF000000), ) DayHeaderLine() } @@ -532,25 +606,33 @@ private fun RowScope.DayHeaderLine() { modifier = Modifier .weight(1f) .align(Alignment.CenterVertically), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + color = Color(0xFF79767F).copy(alpha = 0.45f), ) } @Composable -fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit, onVideoClick: (String) -> Unit = {}) { - +fun ChatItemBubble( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit, + onVideoClick: (String) -> Unit = {}, +) { + // Figma speech-bubble fills: #97A5FF for self, #D5DAFF for others val backgroundBubbleColor = if (isUserMe) { - MaterialTheme.colorScheme.primary + Color(0xFF97A5FF) } else { - MaterialTheme.colorScheme.surfaceVariant + Color(0xFFD5DAFF) } + val bubbleShape = if (isUserMe) SelfChatBubbleShape else OtherChatBubbleShape - Column { + Column( + horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start, + ) { val hasText = message.content.isNotBlank() || (message.image == null && message.videoUri == null) if (hasText) { Surface( color = backgroundBubbleColor, - shape = ChatBubbleShape, + shape = bubbleShape, ) { ClickableMessage( message = message, @@ -566,7 +648,7 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) } Surface( color = backgroundBubbleColor, - shape = ChatBubbleShape, + shape = bubbleShape, ) { Image( painter = painterResource(it), @@ -583,16 +665,16 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) } Surface( color = backgroundBubbleColor, - shape = ChatBubbleShape, + shape = bubbleShape, ) { VideoThumbnail( videoUri = videoUri, onClick = { onVideoClick(videoUri) }, - shape = ChatBubbleShape, + shape = bubbleShape, modifier = Modifier .fillMaxWidth() .height(200.dp) - .clip(ChatBubbleShape), + .clip(bubbleShape), ) } } @@ -605,13 +687,16 @@ fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String val styledMessage = messageFormatter( text = message.content, - primary = isUserMe, + primary = false, ) ClickableText( text = styledMessage, - style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), - modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyLarge.copy( + color = Color(0xFF000000), + lineHeight = androidx.compose.ui.unit.TextUnit(24f, androidx.compose.ui.unit.TextUnitType.Sp), + ), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), onClick = { styledMessage .getStringAnnotations(start = it, end = it) From c04e3e019d312966f5346faedf1e7d9e1b01e391 Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 17 Sep 2026 09:33:14 +0530 Subject: [PATCH 06/17] record button restored --- .../compose/jetchat/conversation/UserInput.kt | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 1588619b38..684e952b2a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -177,6 +177,10 @@ fun UserInput( } } + // Toggled when the user clicks the recording mic icon. Drives both the animated mesh-gradient + // glow behind the input card and the recording button's gradient fill. + var isRecordingActive by rememberSaveable { mutableStateOf(false) } + val sendMessage = { val currentVideoUri = attachedVideoUri if (currentVideoUri != null) { @@ -186,6 +190,7 @@ fun UserInput( onMessageSent(textState.text) } textState = TextFieldValue() + isRecordingActive = false resetScroll() dismissKeyboard() } @@ -197,9 +202,7 @@ fun UserInput( val surfaceColor = Color(0xFFEAE9FC) val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null - // Gemini is "active" only when the message mentions @gemini. Drives both the glow and - // the spark button's gradient fill. - val isGeminiActive = textState.text.contains("@gemini", ignoreCase = true) + val isGlowActive = isRecordingActive || textState.text.contains("@gemini", ignoreCase = true) Column( modifier = modifier @@ -207,17 +210,14 @@ fun UserInput( .padding(start = 8.dp, end = 4.dp, bottom = 8.dp, top = 6.dp), ) { Box(modifier = Modifier.fillMaxWidth()) { - // Animated mesh-gradient glow behind the card. Fades in/out as the message - // gains/loses @gemini. The soft feather is produced entirely by the mesh (bicubic - // colour + fully transparent boundary vertices), so there is no blur and no - // linear gradient. The layout() lets the glow bleed above/below/beside the card - // without adding size to the parent Column. + // Animated mesh-gradient glow behind the card. Shown when recording mode is active + // (triggered on click of the recording icon) or when the message mentions @gemini. val glowAlpha by animateFloatAsState( - targetValue = if (isGeminiActive) 1f else 0f, + targetValue = if (isGlowActive) 1f else 0f, animationSpec = tween(durationMillis = 600), label = "glowFade", ) - if (glowAlpha > 0f) { + if (isGlowActive) { Box( modifier = Modifier .matchParentSize() @@ -244,14 +244,14 @@ fun UserInput( shape = cardShape, color = surfaceColor, // Default soft shadow when Gemini is idle; the glow replaces it when active. - shadowElevation = if (isGeminiActive) 4.dp else 8.dp, + shadowElevation = if (isGlowActive) 4.dp else 8.dp, modifier = Modifier .fillMaxWidth() .padding(end = 4.dp) // Default blue-tinted shadow when Gemini is idle; the glow replaces it // when active. Tinted shadows render on API 28+ (black on older versions). .then( - if (isGeminiActive) { + if (isGlowActive) { Modifier } else { Modifier.shadow( @@ -314,7 +314,8 @@ fun UserInput( UserInputSelector( onSelectorChange = { currentInputSelector = it }, currentInputSelector = currentInputSelector, - geminiActive = isGeminiActive, + recordingActive = isGlowActive, + onRecordingClick = { isRecordingActive = !isRecordingActive }, onVideoClick = { currentInputSelector = InputSelector.NONE videoPickerLauncher.launch("video/*") @@ -456,7 +457,8 @@ fun FunctionalityNotAvailablePanel() { private fun UserInputSelector( onSelectorChange: (InputSelector) -> Unit, currentInputSelector: InputSelector, - geminiActive: Boolean, + recordingActive: Boolean, + onRecordingClick: () -> Unit, modifier: Modifier = Modifier, onVideoClick: () -> Unit = {}, onAddClick: () -> Unit = {}, @@ -506,32 +508,32 @@ private fun UserInputSelector( ) } - // Gemini spark button. Only active (mesh-gradient circle + white icon) when the - // message mentions @gemini; otherwise it's a plain blue-tinted icon like the others. - if (geminiActive) { + // Recording mic button. Active (mesh-gradient circle + white mic icon) when triggered + // via click; otherwise a blue-tinted mic icon matching Figma 191:24835. + if (recordingActive) { Box( modifier = Modifier .size(48.dp) .clip(CircleShape) .paint(sparkMeshPainter, contentScale = ContentScale.FillBounds) - .clickable { onSelectorChange(InputSelector.DM) }, + .clickable { onRecordingClick() }, contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(id = R.drawable.ic_spark), - contentDescription = stringResource(id = R.string.dm_desc), + painter = painterResource(id = R.drawable.ic_mic), + contentDescription = stringResource(id = R.string.record_message), tint = Color.White, modifier = Modifier.size(24.dp), ) } } else { IconButton( - onClick = { onSelectorChange(InputSelector.DM) }, + onClick = onRecordingClick, modifier = Modifier.size(48.dp), ) { Icon( - painter = painterResource(id = R.drawable.ic_spark), - contentDescription = stringResource(id = R.string.dm_desc), + painter = painterResource(id = R.drawable.ic_mic), + contentDescription = stringResource(id = R.string.record_message), tint = iconTint, modifier = Modifier.size(24.dp), ) From 01b57069f9b6973dec514ccf0b0c1a8d07ec7877 Mon Sep 17 00:00:00 2001 From: raystatic Date: Tue, 29 Sep 2026 00:17:23 +0530 Subject: [PATCH 07/17] update typography of input box and add double to like --- .../compose/jetchat/components/MeshUtils.kt | 35 ++- .../jetchat/conversation/Conversation.kt | 269 +++++++++++++----- .../compose/jetchat/conversation/UserInput.kt | 88 +++--- .../example/compose/jetchat/theme/Color.kt | 1 + .../example/compose/jetchat/theme/Themes.kt | 1 + .../compose/jetchat/theme/Typography.kt | 8 +- 6 files changed, 279 insertions(+), 123 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt index 95fcfb12c6..d7c28dfa0e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -180,6 +180,39 @@ fun rememberUserInputGlowMeshGradientPainter(): MeshGradientPainter { } } +/** + * Heart reaction mesh gradient for liked chat speech bubbles (Figma node 268:29966). + * Vibrant coral, blush, hot pink, and lavender tones. + */ +@Composable +fun rememberHeartReactionMeshGradientPainter(): MeshGradientPainter { + return remember { + MeshGradientPainter( + rows = 2, + columns = 3, + hasBicubicColor = true, + ) { + // Row 0 (top edge, y = 0.0f) + setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B)) + setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6)) + setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF)) + setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF)) + + // Row 1 (mid, y ~ 0.33f - 0.50f) + setVertex(1, 0, Offset(0.0000f, 0.3300f), Color(0xFFFFAAEA)) + setVertex(1, 1, Offset(0.3300f, 0.3300f), Color(0xFFFF6060)) + setVertex(1, 2, Offset(0.6700f, 0.3300f), Color(0xFFFFBCBC)) + setVertex(1, 3, Offset(1.0000f, 0.5000f), Color(0xFFFF2088)) + + // Row 2 (bottom edge, y = 1.0f) + setVertex(2, 0, Offset(0.0000f, 1.0000f), Color(0xFFFFFFFF)) + setVertex(2, 1, Offset(0.3300f, 1.0000f), Color(0xFFD0BEF9)) + setVertex(2, 2, Offset(0.6637f, 1.0000f), Color(0xFFFFA298)) + setVertex(2, 3, Offset(1.0000f, 1.0000f), Color(0xFFFF9BEB)) + } + } +} + /** * A small static mesh gradient for the Gemini spark button: a pink → purple → blue diagonal * built from a 2x2-cell mesh (3x3 vertices) with bicubic colour blending. This reproduces the @@ -212,4 +245,4 @@ fun rememberUserInputSparkMeshGradientPainter(): MeshGradientPainter { } } } -} +} \ No newline at end of file diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index f0333ec7ba..44e092a2eb 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -20,15 +20,24 @@ package com.example.compose.jetchat.conversation import android.content.ClipDescription import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -44,8 +53,8 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -53,11 +62,9 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults @@ -67,8 +74,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -82,25 +91,29 @@ import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.paint -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R -import com.example.compose.jetchat.blur.BlurRadiusSpec import com.example.compose.jetchat.blur.backdropBlur import com.example.compose.jetchat.components.JetchatAppBar +import com.example.compose.jetchat.components.rememberHeartReactionMeshGradientPainter import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme import com.example.compose.jetchat.video.FullScreenVideoPlayer @@ -180,9 +193,9 @@ fun ConversationContent( var activeVideoUri by rememberSaveable { mutableStateOf(null) } - Box(modifier = modifier.fillMaxSize().background(color = Color(0xFFEAE9FC))) { + Box(modifier = modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surfaceContainer)) { Scaffold( - containerColor = Color(0xFFEAE9FC), + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { ChannelNameBar( channelName = uiState.channelName, @@ -196,7 +209,6 @@ fun ConversationContent( .contentWindowInsets .exclude(WindowInsets.navigationBars) .exclude(WindowInsets.ime), -// containerColor = Color.Transparent, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Column( @@ -279,7 +291,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = Color(0xFFEAE9FC).copy(alpha = 0.5f), + tint = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f), elevation = 0.dp, radius = 12.dp, ), @@ -291,13 +303,13 @@ fun ChannelNameBar( Text( text = channelName, style = MaterialTheme.typography.titleMedium, - color = Color(0xFF000965) + color = MaterialTheme.colorScheme.onSurface, ) // Number of members Text( text = stringResource(R.string.members, channelMembers), style = MaterialTheme.typography.bodySmall, - color = Color(0xFF444746), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, @@ -419,12 +431,12 @@ fun Message( val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 12.dp) else Modifier if (isUserMe) { - // Self messages: right-aligned bubble (#97A5FF) with avatar on the right (Figma 191:25321, 191:25358) + // Self messages: right-aligned bubble with avatar on the right (Figma 191:25321) Row( modifier = spaceBetweenAuthors .fillMaxWidth() .padding(horizontal = 16.dp), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.End, + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.Top, ) { AuthorAndTextMessage( @@ -444,7 +456,7 @@ fun Message( modifier = Modifier .clickable(onClick = { onAuthorClick(msg.author) }) .size(48.dp) - .border(1.5.dp, Color(0xFF97A5FF), CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.inversePrimary, CircleShape) .clip(CircleShape), painter = painterResource(id = msg.authorImage), contentScale = ContentScale.Crop, @@ -455,7 +467,7 @@ fun Message( } } } else { - // Other user messages: left-aligned avatar + pill badge header + bubble (#D5DAFF) (Figma 191:25301, 191:25334) + // Other user messages: left-aligned avatar + pill badge header + bubble (Figma 191:25301) Column( modifier = spaceBetweenAuthors .fillMaxWidth() @@ -474,7 +486,7 @@ fun Message( modifier = Modifier .clickable(onClick = { onAuthorClick(msg.author) }) .size(48.dp) - .border(1.5.dp, Color(0xFF1E40FF).copy(alpha = 0.2f), CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape) .clip(CircleShape), painter = painterResource(id = msg.authorImage), contentScale = ContentScale.Crop, @@ -531,15 +543,11 @@ fun AuthorAndTextMessage( } @Composable -private fun AuthorNameTimestamp( - msg: Message, - onAuthorClick: (String) -> Unit = {}, -) { +private fun AuthorNameTimestamp(msg: Message, onAuthorClick: (String) -> Unit = {}) { // Figma name+time pill badge (id=191:25314, 191:25347) - // Fill: #EAE9FC + 20% #1E40FF (~#D4D9FC), cornerRadius = 24.dp Surface( shape = RoundedCornerShape(24.dp), - color = Color(0xFFD4D9FC), + color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier .clip(RoundedCornerShape(24.dp)) .clickable { onAuthorClick(msg.author) } @@ -552,13 +560,13 @@ private fun AuthorNameTimestamp( Text( text = msg.author, style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF1E40FF), + color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = msg.timestamp, style = MaterialTheme.typography.bodySmall, - color = Color(0xFF836FB2), + color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } @@ -592,9 +600,9 @@ fun DayHeader(dayString: String) { text = dayString, modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.labelMedium.copy( - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + fontWeight = FontWeight.SemiBold, ), - color = Color(0xFF000000), + color = MaterialTheme.colorScheme.onSurface, ) DayHeaderLine() } @@ -606,24 +614,44 @@ private fun RowScope.DayHeaderLine() { modifier = Modifier .weight(1f) .align(Alignment.CenterVertically), - color = Color(0xFF79767F).copy(alpha = 0.45f), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f), ) } @Composable -fun ChatItemBubble( - message: Message, - isUserMe: Boolean, - authorClicked: (String) -> Unit, - onVideoClick: (String) -> Unit = {}, -) { - // Figma speech-bubble fills: #97A5FF for self, #D5DAFF for others +fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit, onVideoClick: (String) -> Unit = {}) { + var isLiked by rememberSaveable( + message.timestamp, + message.author, + message.content, + ) { + mutableStateOf(false) + } + + var likeAnimationTrigger by remember { mutableIntStateOf(0) } + val haptic = LocalHapticFeedback.current + val heartMeshPainter = rememberHeartReactionMeshGradientPainter() + val backgroundBubbleColor = if (isUserMe) { - Color(0xFF97A5FF) + MaterialTheme.colorScheme.inversePrimary } else { - Color(0xFFD5DAFF) + MaterialTheme.colorScheme.primaryContainer } val bubbleShape = if (isUserMe) SelfChatBubbleShape else OtherChatBubbleShape + val bubbleBorder = if (isLiked) { + BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface) + } else { + null + } + + val toggleLiked = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + val nextLiked = !isLiked + isLiked = nextLiked + if (nextLiked) { + likeAnimationTrigger++ + } + } Column( horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start, @@ -631,14 +659,31 @@ fun ChatItemBubble( val hasText = message.content.isNotBlank() || (message.image == null && message.videoUri == null) if (hasText) { Surface( - color = backgroundBubbleColor, + color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, + border = bubbleBorder, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onDoubleTap = { toggleLiked() }) + }, ) { - ClickableMessage( - message = message, - isUserMe = isUserMe, - authorClicked = authorClicked, - ) + Box( + modifier = if (isLiked) { + Modifier + .background(MaterialTheme.colorScheme.surface) + .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ) { + ClickableMessage( + message = message, + isUserMe = isUserMe, + authorClicked = authorClicked, + isLiked = isLiked, + likeAnimationTrigger = likeAnimationTrigger, + onDoubleClick = toggleLiked, + ) + } } } @@ -647,15 +692,31 @@ fun ChatItemBubble( Spacer(modifier = Modifier.height(4.dp)) } Surface( - color = backgroundBubbleColor, + color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, + border = bubbleBorder, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onDoubleTap = { toggleLiked() }) + }, ) { - Image( - painter = painterResource(it), - contentScale = ContentScale.Fit, - modifier = Modifier.size(160.dp), - contentDescription = stringResource(id = R.string.attached_image), - ) + Box( + modifier = if (isLiked) { + Modifier + .background(MaterialTheme.colorScheme.surface) + .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ) { + Image( + painter = painterResource(it), + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(if (isLiked) 8.dp else 0.dp) + .size(160.dp), + contentDescription = stringResource(id = R.string.attached_image), + ) + } } } @@ -664,8 +725,12 @@ fun ChatItemBubble( Spacer(modifier = Modifier.height(4.dp)) } Surface( - color = backgroundBubbleColor, + color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, + border = bubbleBorder, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onDoubleTap = { toggleLiked() }) + }, ) { VideoThumbnail( videoUri = videoUri, @@ -678,11 +743,43 @@ fun ChatItemBubble( ) } } + + // Liked reaction badge attached to bubble corner + this@Column.AnimatedVisibility( + visible = isLiked, + enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = Modifier + .align(Alignment.End) + .offset(y = (-8).dp) + .padding(end = 4.dp), + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("❤️", fontSize = 12.sp) + } + } + } } } @Composable -fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { +fun ClickableMessage( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit, + isLiked: Boolean = false, + likeAnimationTrigger: Int = 0, + onDoubleClick: () -> Unit = {}, +) { val uriHandler = LocalUriHandler.current val styledMessage = messageFormatter( @@ -690,25 +787,59 @@ fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String primary = false, ) - ClickableText( + var layoutResult by remember { mutableStateOf(null) } + + // Text bounce animation when message is liked + val textScale = remember { Animatable(1f) } + + LaunchedEffect(likeAnimationTrigger) { + if (likeAnimationTrigger > 0 && isLiked) { + textScale.animateTo( + targetValue = 1.12f, + animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing), + ) + textScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + } + + Text( text = styledMessage, style = MaterialTheme.typography.bodyLarge.copy( - color = Color(0xFF000000), - lineHeight = androidx.compose.ui.unit.TextUnit(24f, androidx.compose.ui.unit.TextUnitType.Sp), + color = MaterialTheme.colorScheme.onSurface, ), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - onClick = { - styledMessage - .getStringAnnotations(start = it, end = it) - .firstOrNull() - ?.let { annotation -> - when (annotation.tag) { - SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) - SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) - else -> Unit - } - } - }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .graphicsLayer { + scaleX = textScale.value + scaleY = textScale.value + } + .pointerInput(styledMessage) { + detectTapGestures( + onDoubleTap = { onDoubleClick() }, + onTap = { offset -> + layoutResult?.let { layout -> + val position = layout.getOffsetForPosition(offset) + styledMessage + .getStringAnnotations(start = position, end = position) + .firstOrNull() + ?.let { annotation -> + when (annotation.tag) { + SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) + SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) + else -> Unit + } + } + } + }, + ) + }, + onTextLayout = { layoutResult = it }, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 684e952b2a..0706bccd1f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size @@ -75,6 +76,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -89,7 +91,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -97,35 +98,28 @@ import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.Alignment -import androidx.compose.ui.semantics.text -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R -import com.example.compose.jetchat.video.VideoPlayer import com.example.compose.jetchat.components.rememberUserInputGlowMeshGradientPainter import com.example.compose.jetchat.components.rememberUserInputSparkMeshGradientPainter -import com.example.compose.jetchat.theme.KarlaFontFamily +import com.example.compose.jetchat.video.VideoPlayer +import kotlinx.coroutines.delay import kotlin.math.absoluteValue import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay enum class InputSelector { NONE, @@ -200,7 +194,7 @@ fun UserInput( val glowMeshPainter = rememberUserInputGlowMeshGradientPainter() - val surfaceColor = Color(0xFFEAE9FC) + val surfaceColor = MaterialTheme.colorScheme.surfaceContainer val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null val isGlowActive = isRecordingActive || textState.text.contains("@gemini", ignoreCase = true) @@ -221,25 +215,18 @@ fun UserInput( Box( modifier = Modifier .matchParentSize() - .layout { measurable, constraints -> - val topExpand = 185.dp.roundToPx() - val bottomExpand = 68.dp.roundToPx() - val horizontalExpand = 76.dp.roundToPx() - val expandedWidth = constraints.maxWidth + horizontalExpand * 2 - val expandedHeight = constraints.maxHeight + topExpand + bottomExpand - val placeable = measurable.measure( - Constraints.fixed(expandedWidth, expandedHeight), - ) - layout(constraints.maxWidth, constraints.maxHeight) { - placeable.place(-horizontalExpand, -topExpand) - } + .graphicsLayer { + alpha = glowAlpha + scaleX = 1.38f + scaleY = 2.85f + translationY = -58.dp.toPx() } - .graphicsLayer { alpha = glowAlpha } .paint(glowMeshPainter, contentScale = ContentScale.FillBounds), ) } val cardShape = RoundedCornerShape(48.dp) + Surface( shape = cardShape, color = surfaceColor, @@ -258,17 +245,17 @@ fun UserInput( elevation = 16.dp, shape = cardShape, clip = false, - ambientColor = Color(0xFF3E41F4), - spotColor = Color(0xFF3E41F4), + ambientColor = MaterialTheme.colorScheme.primary, + spotColor = MaterialTheme.colorScheme.primary, ) }, ) - .heightIn(min = 160.dp), + .heightIn(min = 136.dp), ) { Column( modifier = Modifier .fillMaxWidth() - .heightIn(min = 160.dp), + .heightIn(min = 136.dp), verticalArrangement = Arrangement.SpaceBetween, ) { Column(modifier = Modifier.fillMaxWidth()) { @@ -309,7 +296,7 @@ fun UserInput( .height(56.dp) .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { UserInputSelector( onSelectorChange = { currentInputSelector = it }, @@ -330,12 +317,13 @@ fun UserInput( Icon( painter = painterResource(id = R.drawable.ic_send), contentDescription = null, - tint = Color(0xFF444746).copy(alpha = if (sendMessageEnabled) 0.85f else 0.54f), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (sendMessageEnabled) 0.85f else 0.54f, + ), modifier = Modifier.size(24.dp), ) } } - } } } @@ -463,7 +451,7 @@ private fun UserInputSelector( onVideoClick: () -> Unit = {}, onAddClick: () -> Unit = {}, ) { - val iconTint = Color(0xFF3E41F4) + val iconTint = MaterialTheme.colorScheme.primary val sparkMeshPainter = rememberUserInputSparkMeshGradientPainter() Row( @@ -508,8 +496,8 @@ private fun UserInputSelector( ) } - // Recording mic button. Active (mesh-gradient circle + white mic icon) when triggered - // via click; otherwise a blue-tinted mic icon matching Figma 191:24835. + // Recording mic button. Active (white rounded rect + primary mic icon per Figma 289:33323) + // when triggered via click; otherwise an unhighlighted primary mic icon. if (recordingActive) { Box( modifier = Modifier @@ -548,7 +536,7 @@ private fun UserInputSelector( Icon( painter = painterResource(id = R.drawable.ic_add), contentDescription = stringResource(id = R.string.map_selector_desc), - tint = Color(0xFF0B57D0), + tint = iconTint, modifier = Modifier.size(24.dp), ) } @@ -611,12 +599,12 @@ private fun UserInputText( focusState: Boolean, ) { val a11ylabel = stringResource(id = R.string.textfield_desc) - // Figma 'Chat' text area at x=32dp, top=20dp, end=40dp. + // Figma 'Chat' text area (191:24767): 380x48dp inside 396x136dp card. Box( modifier = Modifier .fillMaxWidth() - .padding(start = 32.dp, top = 20.dp, end = 40.dp) - .heightIn(min = 64.dp), + .padding(start = 24.dp, top = 20.dp, end = 32.dp) + .heightIn(min = 48.dp), ) { UserInputTextField( textFieldValue, @@ -638,7 +626,7 @@ private fun UserInputText( @Composable private fun BoxScope.UserInputTextField( textFieldValue: TextFieldValue, - onTextChanged: (TextFieldValue) -> Unit, + onValueChange: (TextFieldValue) -> Unit, onTextFieldFocused: (Boolean) -> Unit, keyboardType: KeyboardType, focusState: Boolean, @@ -656,8 +644,7 @@ private fun BoxScope.UserInputTextField( text = AnnotatedString(annotated.text + "|"), offsetMapping = object : OffsetMapping { override fun originalToTransformed(offset: Int): Int = offset - override fun transformedToOriginal(offset: Int): Int = - offset.coerceAtMost(annotated.text.length) + override fun transformedToOriginal(offset: Int): Int = offset.coerceAtMost(annotated.text.length) }, ) } else { @@ -671,7 +658,7 @@ private fun BoxScope.UserInputTextField( BasicTextField( value = textFieldValue, - onValueChange = { onTextChanged(it) }, + onValueChange = { onValueChange(it) }, modifier = modifier .align(Alignment.TopStart) .onFocusChanged { state -> @@ -689,15 +676,18 @@ private fun BoxScope.UserInputTextField( }, maxLines = 4, visualTransformation = unfocusedCaret, - cursorBrush = SolidColor(Color(0xFF000965)), - textStyle = MaterialTheme.typography.headlineSmall.copy(color = Color(0xFF001CBA)), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.primary, + ), ) if (textFieldValue.text.isEmpty() && !focusState) { Text( modifier = Modifier.align(Alignment.TopStart), text = stringResource(R.string.textfield_hint), - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt index 35d5181f7d..43a1b75431 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt @@ -58,3 +58,4 @@ val BlueGrey50 = Color(0xFF767680) val BlueGrey60 = Color(0xFF90909A) val BlueGrey80 = Color(0xFFC6C5D0) val BlueGrey90 = Color(0xFFE2E1EC) +val BlueGrey95 = Color(0xFFEAE9FC) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt index c323fcd1b5..f77be35996 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt @@ -83,6 +83,7 @@ val JetchatLightColorScheme = lightColorScheme( inverseOnSurface = Grey95, surfaceVariant = BlueGrey90, onSurfaceVariant = BlueGrey30, + surfaceContainer = BlueGrey95, outline = BlueGrey50, ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt index 2ede128c04..905de6249a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -98,10 +98,10 @@ val JetchatTypography = Typography( letterSpacing = 0.sp, ), titleLarge = TextStyle( - fontFamily = MontserratFontFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 24.sp, letterSpacing = 0.sp, ), titleMedium = TextStyle( From f637c8ca316b1d02dc7703980219be118ba01877 Mon Sep 17 00:00:00 2001 From: raystatic Date: Tue, 29 Sep 2026 00:21:51 +0530 Subject: [PATCH 08/17] spotless apply --- .../java/com/example/compose/jetchat/components/MeshUtils.kt | 2 +- .../java/com/example/compose/jetchat/conversation/UserInput.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt index d7c28dfa0e..f16391bb30 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -245,4 +245,4 @@ fun rememberUserInputSparkMeshGradientPainter(): MeshGradientPainter { } } } -} \ No newline at end of file +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 0706bccd1f..4395dd0cea 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -115,11 +115,11 @@ import com.example.compose.jetchat.R import com.example.compose.jetchat.components.rememberUserInputGlowMeshGradientPainter import com.example.compose.jetchat.components.rememberUserInputSparkMeshGradientPainter import com.example.compose.jetchat.video.VideoPlayer -import kotlinx.coroutines.delay import kotlin.math.absoluteValue import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay enum class InputSelector { NONE, From 530b8c011762723c0460027720b3b9639cd03fa2 Mon Sep 17 00:00:00 2001 From: raystatic Date: Tue, 29 Sep 2026 03:01:33 +0530 Subject: [PATCH 09/17] headline typography --- .../com/example/compose/jetchat/conversation/Conversation.kt | 3 ++- .../main/java/com/example/compose/jetchat/theme/Typography.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 44e092a2eb..5c750ebdc9 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -89,6 +89,7 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color @@ -193,7 +194,7 @@ fun ConversationContent( var activeVideoUri by rememberSaveable { mutableStateOf(null) } - Box(modifier = modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surfaceContainer)) { + Box(modifier = modifier.fillMaxSize()) { Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt index 972ae6fe61..360b4aec72 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -78,7 +78,7 @@ val JetchatTypography = Typography( ), headlineLarge = TextStyle( fontFamily = MontserratFontFamily, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, fontSize = 72.sp, lineHeight = 64.sp, letterSpacing = 0.sp, From 46c4fe1fae6fb5e22e260a7e1c6263969f71de3d Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 30 Sep 2026 04:04:38 +0530 Subject: [PATCH 10/17] fix continuous UserInput recomposition from glow mesh painter use IconButton for recording mic button remove transient shadow on liked-message badge --- .../compose/jetchat/components/MeshUtils.kt | 17 +++---- .../jetchat/conversation/Conversation.kt | 1 - .../compose/jetchat/conversation/UserInput.kt | 51 ++++++++----------- 3 files changed, 29 insertions(+), 40 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt index f16391bb30..f4a13f0f29 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -64,16 +64,15 @@ fun rememberUserInputGlowMeshGradientPainter(): MeshGradientPainter { label = "glowPhase2", ) - // Helper for smooth organic orbital displacement per interior vertex. - fun orbit(colIdx: Int, rowIdx: Int, ampX: Float, ampY: Float): Offset { - val angle1 = phase1 + colIdx * 0.85f + rowIdx * 0.65f - val angle2 = phase2 - colIdx * 0.55f + rowIdx * 0.90f - val dx = ampX * (0.72f * cos(angle1) + 0.28f * sin(angle2)) - val dy = ampY * (0.72f * sin(angle1) + 0.28f * cos(angle2)) - return Offset(dx, dy) - } + return remember { + fun orbit(colIdx: Int, rowIdx: Int, ampX: Float, ampY: Float): Offset { + val angle1 = phase1 + colIdx * 0.85f + rowIdx * 0.65f + val angle2 = phase2 - colIdx * 0.55f + rowIdx * 0.90f + val dx = ampX * (0.72f * cos(angle1) + 0.28f * sin(angle2)) + val dy = ampY * (0.72f * sin(angle1) + 0.28f * cos(angle2)) + return Offset(dx, dy) + } - return remember(phase1, phase2) { val uCols = floatArrayOf(0.00f, 0.12f, 0.27f, 0.56f, 0.78f, 1.00f) val vRows = floatArrayOf(0.00f, 0.20f, 0.33f, 0.43f, 0.64f, 0.85f, 1.00f) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 5c750ebdc9..85986fd637 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -758,7 +758,6 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) Surface( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f)), ) { Row( diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 4395dd0cea..0e11b22400 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -192,8 +192,6 @@ fun UserInput( // Used to decide if the keyboard should be shown var textFieldFocusState by remember { mutableStateOf(false) } - val glowMeshPainter = rememberUserInputGlowMeshGradientPainter() - val surfaceColor = MaterialTheme.colorScheme.surfaceContainer val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null val isGlowActive = isRecordingActive || textState.text.contains("@gemini", ignoreCase = true) @@ -212,6 +210,7 @@ fun UserInput( label = "glowFade", ) if (isGlowActive) { + val glowMeshPainter = rememberUserInputGlowMeshGradientPainter() Box( modifier = Modifier .matchParentSize() @@ -498,34 +497,26 @@ private fun UserInputSelector( // Recording mic button. Active (white rounded rect + primary mic icon per Figma 289:33323) // when triggered via click; otherwise an unhighlighted primary mic icon. - if (recordingActive) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .paint(sparkMeshPainter, contentScale = ContentScale.FillBounds) - .clickable { onRecordingClick() }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_mic), - contentDescription = stringResource(id = R.string.record_message), - tint = Color.White, - modifier = Modifier.size(24.dp), - ) - } - } else { - IconButton( - onClick = onRecordingClick, - modifier = Modifier.size(48.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_mic), - contentDescription = stringResource(id = R.string.record_message), - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - } + IconButton( + onClick = onRecordingClick, + modifier = Modifier + .size(48.dp) + .then( + if (recordingActive) { + Modifier + .clip(CircleShape) + .paint(sparkMeshPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_mic), + contentDescription = stringResource(id = R.string.record_message), + tint = if (recordingActive) Color.White else iconTint, + modifier = Modifier.size(24.dp), + ) } // Add / attachment From 449ac1cf2ffea88fb1b47be232bc1300a2cd3a5d Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 30 Sep 2026 16:01:37 +0530 Subject: [PATCH 11/17] Address review feedback on mesh gradient PR --- .../compose/jetchat/components/MeshUtils.kt | 21 +- .../jetchat/conversation/Conversation.kt | 147 +++---- .../conversation/ConversationFragment.kt | 6 +- .../conversation/ConversationUiState.kt | 9 + .../conversation/ConversationViewModel.kt | 38 ++ .../jetchat/conversation/MessageFormatter.kt | 29 +- .../jetchat/conversation/RecordButton.kt | 189 --------- .../compose/jetchat/conversation/UserInput.kt | 379 ++++++------------ .../compose/jetchat/theme/Typography.kt | 2 +- .../app/src/main/res/drawable/ic_spark.xml | 9 - Jetchat/app/src/main/res/values/strings.xml | 2 - 11 files changed, 256 insertions(+), 575 deletions(-) create mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt delete mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt delete mode 100644 Jetchat/app/src/main/res/drawable/ic_spark.xml diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt index f4a13f0f29..205da7a75b 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -180,7 +180,7 @@ fun rememberUserInputGlowMeshGradientPainter(): MeshGradientPainter { } /** - * Heart reaction mesh gradient for liked chat speech bubbles (Figma node 268:29966). + * Heart reaction mesh gradient for liked chat speech bubbles. * Vibrant coral, blush, hot pink, and lavender tones. */ @Composable @@ -213,19 +213,18 @@ fun rememberHeartReactionMeshGradientPainter(): MeshGradientPainter { } /** - * A small static mesh gradient for the Gemini spark button: a pink → purple → blue diagonal - * built from a 2x2-cell mesh (3x3 vertices) with bicubic colour blending. This reproduces the - * Figma diagonal fill without a linear gradient, keeping the screen mesh-only. + * A small static mesh gradient that fills the record (mic) button while recording is active: + * a pink → purple → blue diagonal built from a 2x2-cell mesh (3x3 vertices) with bicubic + * color blending. */ @Composable -fun rememberUserInputSparkMeshGradientPainter(): MeshGradientPainter { - val pink = Color(0xFFF96BD6) - val pinkPurple = Color(0xFFC072EA) - val purple = Color(0xFF9378FF) - val purpleBlue = Color(0xFF585CFF) - val blue = Color(0xFF1E40FF) - +fun rememberRecordButtonMeshGradientPainter(): MeshGradientPainter { return remember { + val pink = Color(0xFFF96BD6) + val pinkPurple = Color(0xFFC072EA) + val purple = Color(0xFF9378FF) + val purpleBlue = Color(0xFF585CFF) + val blue = Color(0xFF1E40FF) val positions = floatArrayOf(0f, 0.5f, 1f) val colors = arrayOf( arrayOf(pink, pinkPurple, purple), diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 85986fd637..89997917b2 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -20,9 +20,9 @@ package com.example.compose.jetchat.conversation import android.content.ClipDescription import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -74,13 +74,12 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -89,7 +88,6 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color @@ -100,12 +98,10 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -119,6 +115,8 @@ import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme import com.example.compose.jetchat.video.FullScreenVideoPlayer import com.example.compose.jetchat.video.VideoThumbnail +import kotlin.math.PI +import kotlin.math.sin import kotlinx.coroutines.launch /** @@ -128,6 +126,7 @@ import kotlinx.coroutines.launch * @param navigateToProfile User action when navigation to a profile is requested * @param modifier [Modifier] to apply to this layout node * @param onNavIconPressed Sends an event up when the user clicks on the menu + * @param onMessageLikeToggled Sends an event up when the user double taps a message to like it */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -136,6 +135,7 @@ fun ConversationContent( navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, onNavIconPressed: () -> Unit = { }, + onMessageLikeToggled: (messageId: String) -> Unit = { }, ) { val authorMe = stringResource(R.string.author_me) val timeNow = stringResource(id = R.string.now) @@ -232,6 +232,7 @@ fun ConversationContent( scrollState = scrollState, contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()), onVideoClick = { videoUri -> activeVideoUri = videoUri }, + onMessageLikeToggled = onMessageLikeToggled, ) UserInput( onMessageSent = { content -> @@ -349,6 +350,7 @@ fun Messages( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), onVideoClick: (String) -> Unit = {}, + onMessageLikeToggled: (messageId: String) -> Unit = {}, ) { val scope = rememberCoroutineScope() Box(modifier = modifier) { @@ -388,6 +390,7 @@ fun Messages( isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, onVideoClick = onVideoClick, + onLikeToggled = onMessageLikeToggled, ) } } @@ -428,11 +431,12 @@ fun Message( isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, onVideoClick: (String) -> Unit = {}, + onLikeToggled: (messageId: String) -> Unit = {}, ) { val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 12.dp) else Modifier if (isUserMe) { - // Self messages: right-aligned bubble with avatar on the right (Figma 191:25321) + // Self messages: right-aligned bubble with avatar on the right Row( modifier = spaceBetweenAuthors .fillMaxWidth() @@ -447,6 +451,7 @@ fun Message( isLastMessageByAuthor = isLastMessageByAuthor, authorClicked = onAuthorClick, onVideoClick = onVideoClick, + onLikeToggled = onLikeToggled, modifier = Modifier .weight(1f, fill = false) .padding(start = 32.dp), @@ -468,7 +473,7 @@ fun Message( } } } else { - // Other user messages: left-aligned avatar + pill badge header + bubble (Figma 191:25301) + // Other user messages: left-aligned avatar + name/timestamp badge header + bubble Column( modifier = spaceBetweenAuthors .fillMaxWidth() @@ -504,6 +509,7 @@ fun Message( isLastMessageByAuthor = isLastMessageByAuthor, authorClicked = onAuthorClick, onVideoClick = onVideoClick, + onLikeToggled = onLikeToggled, modifier = Modifier .weight(1f, fill = false) .padding(end = 32.dp), @@ -522,6 +528,7 @@ fun AuthorAndTextMessage( authorClicked: (String) -> Unit, modifier: Modifier = Modifier, onVideoClick: (String) -> Unit = {}, + onLikeToggled: (messageId: String) -> Unit = {}, ) { Column( modifier = modifier, @@ -532,6 +539,7 @@ fun AuthorAndTextMessage( isUserMe = isUserMe, authorClicked = authorClicked, onVideoClick = onVideoClick, + onLikeToggled = onLikeToggled, ) if (isFirstMessageByAuthor) { // Last bubble before next author @@ -545,7 +553,7 @@ fun AuthorAndTextMessage( @Composable private fun AuthorNameTimestamp(msg: Message, onAuthorClick: (String) -> Unit = {}) { - // Figma name+time pill badge (id=191:25314, 191:25347) + // Author name + timestamp pill badge Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.secondaryContainer, @@ -573,7 +581,7 @@ private fun AuthorNameTimestamp(msg: Message, onAuthorClick: (String) -> Unit = } } -// Figma 191:25313 / 191:25346: cr=[24.0, 24.0, 24.0, 4.0] for other users +// Other users' bubbles use a small bottom-start corner that points towards the avatar private val OtherChatBubbleShape = RoundedCornerShape( topStart = 24.dp, topEnd = 24.dp, @@ -620,16 +628,14 @@ private fun RowScope.DayHeaderLine() { } @Composable -fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit, onVideoClick: (String) -> Unit = {}) { - var isLiked by rememberSaveable( - message.timestamp, - message.author, - message.content, - ) { - mutableStateOf(false) - } - - var likeAnimationTrigger by remember { mutableIntStateOf(0) } +fun ChatItemBubble( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit, + onVideoClick: (String) -> Unit = {}, + onLikeToggled: (messageId: String) -> Unit = {}, +) { + val isLiked = message.isLiked val haptic = LocalHapticFeedback.current val heartMeshPainter = rememberHeartReactionMeshGradientPainter() @@ -645,13 +651,10 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) null } - val toggleLiked = { + // pointerInput(Unit) below captures this once, so always call the latest callback. + val toggleLiked by rememberUpdatedState { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - val nextLiked = !isLiked - isLiked = nextLiked - if (nextLiked) { - likeAnimationTrigger++ - } + onLikeToggled(message.id) } Column( @@ -680,9 +683,6 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) message = message, isUserMe = isUserMe, authorClicked = authorClicked, - isLiked = isLiked, - likeAnimationTrigger = likeAnimationTrigger, - onDoubleClick = toggleLiked, ) } } @@ -755,58 +755,35 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) .offset(y = (-8).dp) .padding(end = 4.dp), ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f)), - ) { - Row( - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text("❤️", fontSize = 12.sp) - } - } + val badgeShape = RoundedCornerShape(12.dp) + Text( + text = "❤️", + fontSize = 12.sp, + modifier = Modifier + .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f), badgeShape) + .background(MaterialTheme.colorScheme.surface, badgeShape) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) } } } @Composable -fun ClickableMessage( - message: Message, - isUserMe: Boolean, - authorClicked: (String) -> Unit, - isLiked: Boolean = false, - likeAnimationTrigger: Int = 0, - onDoubleClick: () -> Unit = {}, -) { - val uriHandler = LocalUriHandler.current - +fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { + // Links and @mentions are LinkAnnotations, so the Text handles their clicks and all other + // taps (e.g. double tap to like) fall through to the bubble. val styledMessage = messageFormatter( text = message.content, primary = false, + onPersonClick = authorClicked, ) - var layoutResult by remember { mutableStateOf(null) } - - // Text bounce animation when message is liked - val textScale = remember { Animatable(1f) } - - LaunchedEffect(likeAnimationTrigger) { - if (likeAnimationTrigger > 0 && isLiked) { - textScale.animateTo( - targetValue = 1.12f, - animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing), - ) - textScale.animateTo( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - ) - } - } + // Goes 0 -> 1 when the message is liked; the text scales up and back down along the way. + val likeProgress by animateFloatAsState( + targetValue = if (message.isLiked) 1f else 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), + label = "likeBounce", + ) Text( text = styledMessage, @@ -816,33 +793,15 @@ fun ClickableMessage( modifier = Modifier .padding(horizontal = 16.dp, vertical = 12.dp) .graphicsLayer { - scaleX = textScale.value - scaleY = textScale.value - } - .pointerInput(styledMessage) { - detectTapGestures( - onDoubleTap = { onDoubleClick() }, - onTap = { offset -> - layoutResult?.let { layout -> - val position = layout.getOffsetForPosition(offset) - styledMessage - .getStringAnnotations(start = position, end = position) - .firstOrNull() - ?.let { annotation -> - when (annotation.tag) { - SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) - SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) - else -> Unit - } - } - } - }, - ) + val scale = if (message.isLiked) 1f + LikeBounceScale * sin(PI.toFloat() * likeProgress) else 1f + scaleX = scale + scaleY = scale }, - onTextLayout = { layoutResult = it }, ) } +private const val LikeBounceScale = 0.12f + @Preview @Composable fun ConversationPreview() { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt index dfb1ffff9f..a99dce1776 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt @@ -26,15 +26,16 @@ import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.navigation.findNavController import com.example.compose.jetchat.MainViewModel import com.example.compose.jetchat.R -import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme class ConversationFragment : Fragment() { private val activityViewModel: MainViewModel by activityViewModels() + private val viewModel: ConversationViewModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = ComposeView(inflater.context).apply { @@ -43,7 +44,7 @@ class ConversationFragment : Fragment() { setContent { JetchatTheme { ConversationContent( - uiState = exampleUiState, + uiState = viewModel.uiState, navigateToProfile = { user -> // Click callback val bundle = bundleOf("userId" to user) @@ -55,6 +56,7 @@ class ConversationFragment : Fragment() { onNavIconPressed = { activityViewModel.openDrawer() }, + onMessageLikeToggled = viewModel::toggleLike, ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt index d53ad941c1..a662ec6952 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -27,6 +27,14 @@ class ConversationUiState(val channelName: String, val channelMembers: Int, init fun addMessage(msg: Message) { _messages.add(0, msg) // Add to the beginning of the list } + + fun toggleLike(messageId: String) { + val index = _messages.indexOfFirst { it.id == messageId } + if (index != -1) { + val message = _messages[index] + _messages[index] = message.copy(isLiked = !message.isLiked) + } + } } @Immutable @@ -38,4 +46,5 @@ data class Message( val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, val videoUri: String? = null, val id: String = "$author-$timestamp-$content", + val isLiked: Boolean = false, ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt new file mode 100644 index 0000000000..c503ff822a --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.conversation + +import androidx.lifecycle.ViewModel +import com.example.compose.jetchat.data.exampleUiState +import com.example.compose.jetchat.data.initialMessages + +/** + * Holds the conversation state so it survives configuration changes, and handles message + * actions such as liking a message. + */ +class ConversationViewModel : ViewModel() { + + val uiState = ConversationUiState( + channelName = exampleUiState.channelName, + channelMembers = exampleUiState.channelMembers, + initialMessages = initialMessages, + ) + + fun toggleLike(messageId: String) { + uiState.toggleLike(messageId) + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt index 17701ad325..4c112dabae 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt @@ -19,9 +19,12 @@ package com.example.compose.jetchat.conversation import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -29,6 +32,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.sp // Regex containing the syntax tokens @@ -55,11 +59,13 @@ typealias SymbolAnnotation = Pair * | `MyClass.myMethod` -> inline code styling * * @param text contains message to be parsed - * @return AnnotatedString with annotations used inside the ClickableText wrapper + * @param onPersonClick called with the username when an @mention is clicked + * @return AnnotatedString with links for URLs and @mentions */ @Composable -fun messageFormatter(text: String, primary: Boolean): AnnotatedString { +fun messageFormatter(text: String, primary: Boolean, onPersonClick: (String) -> Unit = {}): AnnotatedString { val colorScheme = MaterialTheme.colorScheme + val currentOnPersonClick by rememberUpdatedState(onPersonClick) return remember(text, primary, colorScheme) { val tokens = symbolPattern.findAll(text) buildAnnotatedString { @@ -81,11 +87,22 @@ fun messageFormatter(text: String, primary: Boolean): AnnotatedString { primary = primary, codeSnippetBackground = codeSnippetBackground, ) - append(annotatedString) - if (stringAnnotation != null) { - val (item, start, end, tag) = stringAnnotation - addStringAnnotation(tag = tag, start = start, end = end, annotation = item) + val link = stringAnnotation?.let { annotation -> + when (annotation.tag) { + SymbolAnnotationType.LINK.name -> LinkAnnotation.Url(annotation.item) + + SymbolAnnotationType.PERSON.name -> LinkAnnotation.Clickable(annotation.item) { + currentOnPersonClick(annotation.item) + } + + else -> null + } + } + if (link != null) { + withLink(link) { append(annotatedString) } + } else { + append(annotatedString) } cursorPosition = token.range.last + 1 diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt deleted file mode 100644 index a1b1c26a18..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.conversation - -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.RichTooltip -import androidx.compose.material3.Text -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.TooltipState -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.compose.jetchat.R -import kotlin.math.abs -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RecordButton( - recording: Boolean, - swipeOffset: () -> Float, - onSwipeOffsetChange: (Float) -> Unit, - onStartRecording: () -> Boolean, - onFinishRecording: () -> Unit, - onCancelRecording: () -> Unit, - modifier: Modifier = Modifier, -) { - val transition = updateTransition(targetState = recording, label = "record") - val scale = transition.animateFloat( - transitionSpec = { spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow) }, - label = "record-scale", - targetValueByState = { rec -> if (rec) 2f else 1f }, - ) - val containerAlpha = transition.animateFloat( - transitionSpec = { tween(2000) }, - label = "record-scale", - targetValueByState = { rec -> if (rec) 1f else 0f }, - ) - val iconColor = transition.animateColor( - transitionSpec = { tween(200) }, - label = "record-scale", - targetValueByState = { rec -> - if (rec) contentColorFor(LocalContentColor.current) - else LocalContentColor.current - }, - ) - - Box { - // Background during recording - Box( - Modifier - .matchParentSize() - .aspectRatio(1f) - .graphicsLayer { - alpha = containerAlpha.value - scaleX = scale.value - scaleY = scale.value - } - .clip(CircleShape) - .background(LocalContentColor.current), - ) - val scope = rememberCoroutineScope() - val tooltipState = remember { TooltipState() } - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - positioning = TooltipAnchorPosition.Above, - ), - tooltip = { - RichTooltip { - Text(stringResource(R.string.touch_and_hold_to_record)) - } - }, - enableUserInput = false, - state = tooltipState, - ) { - Icon( - painterResource(id = R.drawable.ic_mic), - contentDescription = stringResource(R.string.record_message), - tint = iconColor.value, - modifier = modifier - .sizeIn(minWidth = 56.dp, minHeight = 6.dp) - .padding(18.dp) - .clickable { } - .voiceRecordingGesture( - horizontalSwipeProgress = swipeOffset, - onSwipeProgressChanged = onSwipeOffsetChange, - onClick = { scope.launch { tooltipState.show() } }, - onStartRecording = onStartRecording, - onFinishRecording = onFinishRecording, - onCancelRecording = onCancelRecording, - ), - ) - } - } -} - -private fun Modifier.voiceRecordingGesture( - horizontalSwipeProgress: () -> Float, - onSwipeProgressChanged: (Float) -> Unit, - onClick: () -> Unit = {}, - onStartRecording: () -> Boolean = { false }, - onFinishRecording: () -> Unit = {}, - onCancelRecording: () -> Unit = {}, - swipeToCancelThreshold: Dp = 200.dp, - verticalThreshold: Dp = 80.dp, -): Modifier = this - .pointerInput(Unit) { detectTapGestures { onClick() } } - .pointerInput(Unit) { - var offsetY = 0f - var dragging = false - val swipeToCancelThresholdPx = swipeToCancelThreshold.toPx() - val verticalThresholdPx = verticalThreshold.toPx() - - detectDragGesturesAfterLongPress( - onDragStart = { - onSwipeProgressChanged(0f) - offsetY = 0f - dragging = true - onStartRecording() - }, - onDragCancel = { - onCancelRecording() - dragging = false - }, - onDragEnd = { - if (dragging) { - onFinishRecording() - } - dragging = false - }, - onDrag = { change, dragAmount -> - if (dragging) { - onSwipeProgressChanged(horizontalSwipeProgress() + dragAmount.x) - offsetY += dragAmount.y - val offsetX = horizontalSwipeProgress() - if ( - offsetX < 0 && - abs(offsetX) >= swipeToCancelThresholdPx && - abs(offsetY) <= verticalThresholdPx - ) { - onCancelRecording() - dragging = false - } - } - }, - ) - } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 0e11b22400..216e861aed 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -22,11 +22,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically @@ -43,7 +39,6 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -61,15 +56,12 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -79,7 +71,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.paint import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester @@ -88,38 +80,29 @@ import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R +import com.example.compose.jetchat.components.rememberRecordButtonMeshGradientPainter import com.example.compose.jetchat.components.rememberUserInputGlowMeshGradientPainter -import com.example.compose.jetchat.components.rememberUserInputSparkMeshGradientPainter import com.example.compose.jetchat.video.VideoPlayer -import kotlin.math.absoluteValue -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay enum class InputSelector { NONE, @@ -135,7 +118,7 @@ enum class EmojiStickerSelector { STICKER, } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 412, heightDp = 320) +@Preview @Composable fun UserInputPreview() { UserInput(onMessageSent = {}) @@ -194,134 +177,132 @@ fun UserInput( val surfaceColor = MaterialTheme.colorScheme.surfaceContainer val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null - val isGlowActive = isRecordingActive || textState.text.contains("@gemini", ignoreCase = true) + + // Animated mesh-gradient glow behind the card, shown while recording is active. + val glowAlpha by animateFloatAsState( + targetValue = if (isRecordingActive) 1f else 0f, + animationSpec = tween(durationMillis = 600), + label = "glowFade", + ) + val glowMeshPainter = if (isRecordingActive) rememberUserInputGlowMeshGradientPainter() else null Column( modifier = modifier .fillMaxWidth() .padding(start = 8.dp, end = 4.dp, bottom = 8.dp, top = 6.dp), ) { - Box(modifier = Modifier.fillMaxWidth()) { - // Animated mesh-gradient glow behind the card. Shown when recording mode is active - // (triggered on click of the recording icon) or when the message mentions @gemini. - val glowAlpha by animateFloatAsState( - targetValue = if (isGlowActive) 1f else 0f, - animationSpec = tween(durationMillis = 600), - label = "glowFade", - ) - if (isGlowActive) { - val glowMeshPainter = rememberUserInputGlowMeshGradientPainter() - Box( - modifier = Modifier - .matchParentSize() - .graphicsLayer { - alpha = glowAlpha - scaleX = 1.38f - scaleY = 2.85f - translationY = -58.dp.toPx() + val cardShape = RoundedCornerShape(48.dp) + + Surface( + shape = cardShape, + color = surfaceColor, + // Soft shadow while idle; the glow replaces it while recording. + shadowElevation = if (isRecordingActive) 4.dp else 8.dp, + modifier = Modifier + .fillMaxWidth() + // Draw the glow behind the card, scaled past its bounds. + .then( + if (glowMeshPainter != null) { + Modifier.drawBehind { + translate(top = -58.dp.toPx()) { + scale(scaleX = 1.38f, scaleY = 2.85f) { + with(glowMeshPainter) { draw(size, alpha = glowAlpha) } + } + } } - .paint(glowMeshPainter, contentScale = ContentScale.FillBounds), + } else { + Modifier + }, ) - } - - val cardShape = RoundedCornerShape(48.dp) - - Surface( - shape = cardShape, - color = surfaceColor, - // Default soft shadow when Gemini is idle; the glow replaces it when active. - shadowElevation = if (isGlowActive) 4.dp else 8.dp, + .padding(end = 4.dp) + // Blue-tinted shadow while idle; the glow replaces it while recording. + // Tinted shadows render on API 28+ (black on older versions). + .then( + if (isRecordingActive) { + Modifier + } else { + Modifier.shadow( + elevation = 16.dp, + shape = cardShape, + clip = false, + ambientColor = MaterialTheme.colorScheme.primary, + spotColor = MaterialTheme.colorScheme.primary, + ) + }, + ) + .heightIn(min = 136.dp), + ) { + Column( modifier = Modifier .fillMaxWidth() - .padding(end = 4.dp) - // Default blue-tinted shadow when Gemini is idle; the glow replaces it - // when active. Tinted shadows render on API 28+ (black on older versions). - .then( - if (isGlowActive) { - Modifier - } else { - Modifier.shadow( - elevation = 16.dp, - shape = cardShape, - clip = false, - ambientColor = MaterialTheme.colorScheme.primary, - spotColor = MaterialTheme.colorScheme.primary, + .heightIn(min = 136.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + AnimatedVisibility( + visible = attachedVideoUri != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + attachedVideoUri?.let { videoUri -> + AttachedVideoPreview( + videoUri = videoUri, + onRemove = { attachedVideoUri = null }, ) + } + } + + UserInputText( + textFieldValue = textState, + onTextChanged = { textState = it }, + // Only show the keyboard if there's no input selector and text field has focus + keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, + // Close extended selector if text field receives focus + onTextFieldFocused = { focused -> + if (focused) { + currentInputSelector = InputSelector.NONE + resetScroll() + } + textFieldFocusState = focused }, + onMessageSent = { sendMessage() }, + focusState = textFieldFocusState, ) - .heightIn(min = 136.dp), - ) { - Column( + } + + Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 136.dp), - verticalArrangement = Arrangement.SpaceBetween, + .height(56.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.fillMaxWidth()) { - AnimatedVisibility( - visible = attachedVideoUri != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - attachedVideoUri?.let { videoUri -> - AttachedVideoPreview( - videoUri = videoUri, - onRemove = { attachedVideoUri = null }, - ) - } - } - - UserInputText( - textFieldValue = textState, - onTextChanged = { textState = it }, - // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, - // Close extended selector if text field receives focus - onTextFieldFocused = { focused -> - if (focused) { - currentInputSelector = InputSelector.NONE - resetScroll() - } - textFieldFocusState = focused - }, - onMessageSent = { sendMessage() }, - focusState = textFieldFocusState, - ) - } + UserInputSelector( + onSelectorChange = { currentInputSelector = it }, + currentInputSelector = currentInputSelector, + recordingActive = isRecordingActive, + onRecordingClick = { isRecordingActive = !isRecordingActive }, + onVideoClick = { + currentInputSelector = InputSelector.NONE + videoPickerLauncher.launch("video/*") + }, + onAddClick = { currentInputSelector = InputSelector.MAP }, + ) - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + IconButton( + onClick = sendMessage, + enabled = sendMessageEnabled, + modifier = Modifier.size(48.dp), ) { - UserInputSelector( - onSelectorChange = { currentInputSelector = it }, - currentInputSelector = currentInputSelector, - recordingActive = isGlowActive, - onRecordingClick = { isRecordingActive = !isRecordingActive }, - onVideoClick = { - currentInputSelector = InputSelector.NONE - videoPickerLauncher.launch("video/*") - }, - onAddClick = { currentInputSelector = InputSelector.MAP }, + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (sendMessageEnabled) 0.85f else 0.54f, + ), + modifier = Modifier.size(24.dp), ) - - IconButton( - onClick = sendMessage, - modifier = Modifier.clickable(enabled = sendMessageEnabled, onClick = sendMessage).size(48.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_send), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = if (sendMessageEnabled) 0.85f else 0.54f, - ), - modifier = Modifier.size(24.dp), - ) - } } } } @@ -451,7 +432,7 @@ private fun UserInputSelector( onAddClick: () -> Unit = {}, ) { val iconTint = MaterialTheme.colorScheme.primary - val sparkMeshPainter = rememberUserInputSparkMeshGradientPainter() + val recordButtonPainter = rememberRecordButtonMeshGradientPainter() Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -495,8 +476,8 @@ private fun UserInputSelector( ) } - // Recording mic button. Active (white rounded rect + primary mic icon per Figma 289:33323) - // when triggered via click; otherwise an unhighlighted primary mic icon. + // Record (mic) button: filled with a mesh gradient while recording is active, + // otherwise a plain primary-tinted mic icon. IconButton( onClick = onRecordingClick, modifier = Modifier @@ -505,7 +486,7 @@ private fun UserInputSelector( if (recordingActive) { Modifier .clip(CircleShape) - .paint(sparkMeshPainter, contentScale = ContentScale.FillBounds) + .paint(recordButtonPainter, contentScale = ContentScale.FillBounds) } else { Modifier }, @@ -534,42 +515,6 @@ private fun UserInputSelector( } } -@Composable -private fun InputSelectorButton( - onClick: () -> Unit, - icon: androidx.compose.ui.graphics.painter.Painter, - description: String, - selected: Boolean, - modifier: Modifier = Modifier, -) { - val backgroundModifier = if (selected) { - Modifier.background( - color = LocalContentColor.current, - shape = RoundedCornerShape(14.dp), - ) - } else { - Modifier - } - IconButton( - onClick = onClick, - modifier = modifier.then(backgroundModifier), - ) { - val tint = if (selected) { - contentColorFor(backgroundColor = LocalContentColor.current) - } else { - LocalContentColor.current - } - Icon( - icon, - tint = tint, - modifier = Modifier - .padding(8.dp) - .size(56.dp), - contentDescription = description, - ) - } -} - @Composable private fun NotAvailablePopup(onDismissed: () -> Unit) { FunctionalityNotAvailablePopup(onDismissed) @@ -590,7 +535,6 @@ private fun UserInputText( focusState: Boolean, ) { val a11ylabel = stringResource(id = R.string.textfield_desc) - // Figma 'Chat' text area (191:24767): 380x48dp inside 396x136dp card. Box( modifier = Modifier .fillMaxWidth() @@ -617,7 +561,7 @@ private fun UserInputText( @Composable private fun BoxScope.UserInputTextField( textFieldValue: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, + onTextChanged: (TextFieldValue) -> Unit, onTextFieldFocused: (Boolean) -> Unit, keyboardType: KeyboardType, focusState: Boolean, @@ -626,30 +570,9 @@ private fun BoxScope.UserInputTextField( ) { var lastFocusState by remember { mutableStateOf(false) } - // When unfocused, draw a trailing "|" caret after any typed text (matches the Figma mock). - val unfocusedCaret = remember(focusState) { - if (!focusState) { - VisualTransformation { annotated -> - if (annotated.text.isNotEmpty()) { - TransformedText( - text = AnnotatedString(annotated.text + "|"), - offsetMapping = object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int = offset - override fun transformedToOriginal(offset: Int): Int = offset.coerceAtMost(annotated.text.length) - }, - ) - } else { - TransformedText(annotated, OffsetMapping.Identity) - } - } - } else { - VisualTransformation.None - } - } - BasicTextField( value = textFieldValue, - onValueChange = { onValueChange(it) }, + onValueChange = { onTextChanged(it) }, modifier = modifier .align(Alignment.TopStart) .onFocusChanged { state -> @@ -666,7 +589,6 @@ private fun BoxScope.UserInputTextField( if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text) }, maxLines = 4, - visualTransformation = unfocusedCaret, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), textStyle = MaterialTheme.typography.titleLarge.copy( color = MaterialTheme.colorScheme.primary, @@ -683,71 +605,6 @@ private fun BoxScope.UserInputTextField( } } -@Composable -private fun RecordingIndicator(swipeOffset: () -> Float) { - var duration by remember { mutableStateOf(Duration.ZERO) } - LaunchedEffect(Unit) { - while (true) { - delay(1000.milliseconds) - duration += 1.seconds - } - } - Row( - Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - ) { - val infiniteTransition = rememberInfiniteTransition(label = "pulse") - - val animatedPulse = infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.2f, - animationSpec = infiniteRepeatable( - tween(2000), - repeatMode = RepeatMode.Reverse, - ), - label = "pulse", - ) - Box( - Modifier - .size(56.dp) - .padding(24.dp) - .graphicsLayer { - scaleX = animatedPulse.value - scaleY = animatedPulse.value - } - .clip(CircleShape) - .background(Color.Red), - ) - Text( - duration.toComponents { minutes, seconds, _ -> - val min = minutes.toString().padStart(2, '0') - val sec = seconds.toString().padStart(2, '0') - "$min:$sec" - }, - Modifier.alignByBaseline(), - ) - Box( - Modifier - .fillMaxSize() - .alignByBaseline() - .clipToBounds(), - ) { - val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() } - Text( - modifier = Modifier - .align(Alignment.Center) - .graphicsLayer { - translationX = swipeOffset() / 2 - alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold) - }, - textAlign = TextAlign.Center, - text = stringResource(R.string.swipe_to_cancel_recording), - style = MaterialTheme.typography.bodyLarge, - ) - } - } -} - @Composable fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) { var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt index 360b4aec72..972ae6fe61 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -78,7 +78,7 @@ val JetchatTypography = Typography( ), headlineLarge = TextStyle( fontFamily = MontserratFontFamily, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, fontSize = 72.sp, lineHeight = 64.sp, letterSpacing = 0.sp, diff --git a/Jetchat/app/src/main/res/drawable/ic_spark.xml b/Jetchat/app/src/main/res/drawable/ic_spark.xml deleted file mode 100644 index c5aba5a908..0000000000 --- a/Jetchat/app/src/main/res/drawable/ic_spark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index b435de88ff..8021446ccb 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -35,7 +35,6 @@ 8:30 PM %d members Send a message - ◀ Swipe to cancel Emojis Stickers @@ -65,7 +64,6 @@ Search Information More options - Touch and hold to record Record voice message JetChat unread messages Add Widget to Home Page From b2c4458a567ef184306de969ecf2f13f7af799cd Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 30 Sep 2026 17:20:24 +0530 Subject: [PATCH 12/17] nit fixes --- .../com/example/compose/jetchat/components/MeshUtils.kt | 2 +- .../com/example/compose/jetchat/conversation/UserInput.kt | 6 +++--- Jetchat/app/src/main/res/values/strings.xml | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt index 205da7a75b..ee2accfa0e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 216e861aed..1b24a4d6d0 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -297,7 +297,7 @@ fun UserInput( ) { Icon( painter = painterResource(id = R.drawable.ic_send), - contentDescription = null, + contentDescription = stringResource(id = R.string.send), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = if (sendMessageEnabled) 0.85f else 0.54f, ), @@ -490,7 +490,7 @@ private fun UserInputSelector( } else { Modifier }, - ), + ) ) { Icon( painter = painterResource(id = R.drawable.ic_mic), @@ -507,7 +507,7 @@ private fun UserInputSelector( ) { Icon( painter = painterResource(id = R.drawable.ic_add), - contentDescription = stringResource(id = R.string.map_selector_desc), + contentDescription = stringResource(id = R.string.add_attachment_desc), tint = iconTint, modifier = Modifier.size(24.dp), ) diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 8021446ccb..1af056790b 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ Information More options Record voice message + Add attachment JetChat unread messages Add Widget to Home Page From d627fe066b185ccd061a97e68ce7e28e258f9a46 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 30 Sep 2026 17:26:20 +0530 Subject: [PATCH 13/17] ran spotlessApply --- .../java/com/example/compose/jetchat/conversation/UserInput.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 1b24a4d6d0..46630bc655 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -490,7 +490,7 @@ private fun UserInputSelector( } else { Modifier }, - ) + ), ) { Icon( painter = painterResource(id = R.drawable.ic_mic), From 6813cefc3425b702999248ecebb2895bbd03e404 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 30 Sep 2026 19:39:31 +0530 Subject: [PATCH 14/17] Smooth like animation and restore main's media sizing and bubble shapes --- .../jetchat/conversation/Conversation.kt | 99 +++++----- .../compose/jetchat/conversation/UserInput.kt | 174 +++++++++--------- 2 files changed, 147 insertions(+), 126 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 89997917b2..456f607b69 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -22,13 +22,16 @@ import android.content.ClipDescription import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -45,6 +48,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -56,7 +60,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -91,6 +97,7 @@ import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -581,20 +588,8 @@ private fun AuthorNameTimestamp(msg: Message, onAuthorClick: (String) -> Unit = } } -// Other users' bubbles use a small bottom-start corner that points towards the avatar -private val OtherChatBubbleShape = RoundedCornerShape( - topStart = 24.dp, - topEnd = 24.dp, - bottomEnd = 24.dp, - bottomStart = 4.dp, -) - -private val SelfChatBubbleShape = RoundedCornerShape( - topStart = 24.dp, - topEnd = 4.dp, - bottomEnd = 24.dp, - bottomStart = 24.dp, -) +private val ChatBubbleShapeOthers = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) +private val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp) @Composable fun DayHeader(dayString: String) { @@ -644,19 +639,34 @@ fun ChatItemBubble( } else { MaterialTheme.colorScheme.primaryContainer } - val bubbleShape = if (isUserMe) SelfChatBubbleShape else OtherChatBubbleShape + val bubbleShape = if (isUserMe) ChatBubbleShapeMe else ChatBubbleShapeOthers val bubbleBorder = if (isLiked) { BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface) } else { null } + // Inset that reveals the heart mesh around liked media. It is applied inside the fixed-size + // image, so liking never changes the bubble size (no jumps in the LazyColumn). + val likedMediaInset by animateDpAsState( + targetValue = if (isLiked) 8.dp else 0.dp, + label = "likedMediaInset", + ) + // pointerInput(Unit) below captures this once, so always call the latest callback. val toggleLiked by rememberUpdatedState { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onLikeToggled(message.id) } + // Goes 0 -> 1 when the message is liked; the whole text bubble scales up and back down along + // the way. graphicsLayer only affects drawing, so the LazyColumn layout doesn't move. + val likeProgress by animateFloatAsState( + targetValue = if (isLiked) 1f else 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), + label = "likeBounce", + ) + Column( horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start, ) { @@ -666,9 +676,17 @@ fun ChatItemBubble( color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, border = bubbleBorder, - modifier = Modifier.pointerInput(Unit) { - detectTapGestures(onDoubleTap = { toggleLiked() }) - }, + modifier = Modifier + .graphicsLayer { + val scale = if (isLiked) 1f + LikeBounceScale * sin(PI.toFloat() * likeProgress) else 1f + scaleX = scale + scaleY = scale + // Grow away from the avatar, anchored at the bubble's pointed corner. + transformOrigin = TransformOrigin(pivotFractionX = if (isUserMe) 1f else 0f, pivotFractionY = 0f) + } + .pointerInput(Unit) { + detectTapGestures(onDoubleTap = { toggleLiked() }) + }, ) { Box( modifier = if (isLiked) { @@ -681,17 +699,23 @@ fun ChatItemBubble( ) { ClickableMessage( message = message, - isUserMe = isUserMe, authorClicked = authorClicked, ) } } } - message.image?.let { + message.image?.let { imageRes -> if (hasText) { Spacer(modifier = Modifier.height(4.dp)) } + val painter = painterResource(imageRes) + val intrinsicSize = painter.intrinsicSize + val aspectRatio = if (intrinsicSize.width > 0f && intrinsicSize.height > 0f) { + intrinsicSize.width / intrinsicSize.height + } else { + 1f + } Surface( color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, @@ -710,11 +734,13 @@ fun ChatItemBubble( }, ) { Image( - painter = painterResource(it), - contentScale = ContentScale.Fit, + painter = painter, + contentScale = ContentScale.Crop, modifier = Modifier - .padding(if (isLiked) 8.dp else 0.dp) - .size(160.dp), + .sizeIn(maxWidth = 240.dp, maxHeight = 260.dp) + .aspectRatio(aspectRatio, matchHeightConstraintsFirst = aspectRatio < 1f) + .padding(likedMediaInset) + .clip(bubbleShape), contentDescription = stringResource(id = R.string.attached_image), ) } @@ -738,6 +764,7 @@ fun ChatItemBubble( onClick = { onVideoClick(videoUri) }, shape = bubbleShape, modifier = Modifier + .widthIn(max = 260.dp) .fillMaxWidth() .height(200.dp) .clip(bubbleShape), @@ -748,8 +775,11 @@ fun ChatItemBubble( // Liked reaction badge attached to bubble corner this@Column.AnimatedVisibility( visible = isLiked, - enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(), - exit = scaleOut() + fadeOut(), + + enter = expandVertically(expandFrom = Alignment.Top, clip = false) + + scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top, clip = false) + scaleOut() + fadeOut(), modifier = Modifier .align(Alignment.End) .offset(y = (-8).dp) @@ -769,7 +799,7 @@ fun ChatItemBubble( } @Composable -fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { +fun ClickableMessage(message: Message, authorClicked: (String) -> Unit) { // Links and @mentions are LinkAnnotations, so the Text handles their clicks and all other // taps (e.g. double tap to like) fall through to the bubble. val styledMessage = messageFormatter( @@ -778,25 +808,12 @@ fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String onPersonClick = authorClicked, ) - // Goes 0 -> 1 when the message is liked; the text scales up and back down along the way. - val likeProgress by animateFloatAsState( - targetValue = if (message.isLiked) 1f else 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), - label = "likeBounce", - ) - Text( text = styledMessage, style = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface, ), - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) - .graphicsLayer { - val scale = if (message.isLiked) 1f + LikeBounceScale * sin(PI.toFloat() * likeProgress) else 1f - scaleX = scale - scaleY = scale - }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 46630bc655..51f90d1c8c 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -63,6 +64,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -181,31 +183,35 @@ fun UserInput( // Animated mesh-gradient glow behind the card, shown while recording is active. val glowAlpha by animateFloatAsState( targetValue = if (isRecordingActive) 1f else 0f, - animationSpec = tween(durationMillis = 600), + animationSpec = tween(durationMillis = GlowFadeDurationMillis), label = "glowFade", ) - val glowMeshPainter = if (isRecordingActive) rememberUserInputGlowMeshGradientPainter() else null + + val isGlowVisible by remember { derivedStateOf { glowAlpha > 0f } } + val glowMeshPainter = if (isRecordingActive || isGlowVisible) { + rememberUserInputGlowMeshGradientPainter() + } else { + null + } Column( modifier = modifier .fillMaxWidth() .padding(start = 8.dp, end = 4.dp, bottom = 8.dp, top = 6.dp), ) { - val cardShape = RoundedCornerShape(48.dp) + val cardShape = RoundedCornerShape(32.dp) Surface( shape = cardShape, color = surfaceColor, - // Soft shadow while idle; the glow replaces it while recording. - shadowElevation = if (isRecordingActive) 4.dp else 8.dp, modifier = Modifier .fillMaxWidth() // Draw the glow behind the card, scaled past its bounds. .then( if (glowMeshPainter != null) { Modifier.drawBehind { - translate(top = -58.dp.toPx()) { - scale(scaleX = 1.38f, scaleY = 2.85f) { + translate(top = -GlowTopOffset.toPx()) { + scale(scaleX = GlowScaleX, scaleY = GlowScaleY) { with(glowMeshPainter) { draw(size, alpha = glowAlpha) } } } @@ -233,9 +239,7 @@ fun UserInput( .heightIn(min = 136.dp), ) { Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 136.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.SpaceBetween, ) { Column(modifier = Modifier.fillMaxWidth()) { @@ -422,96 +426,91 @@ fun FunctionalityNotAvailablePanel() { } @Composable -private fun UserInputSelector( +private fun RowScope.UserInputSelector( onSelectorChange: (InputSelector) -> Unit, currentInputSelector: InputSelector, recordingActive: Boolean, onRecordingClick: () -> Unit, - modifier: Modifier = Modifier, onVideoClick: () -> Unit = {}, onAddClick: () -> Unit = {}, ) { val iconTint = MaterialTheme.colorScheme.primary val recordButtonPainter = rememberRecordButtonMeshGradientPainter() - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + // Emoji + IconButton( + onClick = { onSelectorChange(InputSelector.EMOJI) }, + modifier = Modifier.size(48.dp), ) { - // Emoji - IconButton( - onClick = { onSelectorChange(InputSelector.EMOJI) }, - modifier = Modifier.size(48.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_mood), - contentDescription = stringResource(id = R.string.emoji_selector_bt_desc), - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - } + Icon( + painter = painterResource(id = R.drawable.ic_mood), + contentDescription = stringResource(id = R.string.emoji_selector_bt_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } - // Photo - IconButton( - onClick = { onSelectorChange(InputSelector.PICTURE) }, - modifier = Modifier.size(48.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_insert_photo), - contentDescription = stringResource(id = R.string.attach_photo_desc), - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - } + // Photo + IconButton( + onClick = { onSelectorChange(InputSelector.PICTURE) }, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_insert_photo), + contentDescription = stringResource(id = R.string.attach_photo_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } - // Video / Duo - IconButton( - onClick = onVideoClick, - modifier = Modifier.size(48.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_duo), - contentDescription = stringResource(id = R.string.videochat_desc), - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - } + // Video / Duo + IconButton( + onClick = onVideoClick, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_duo), + contentDescription = stringResource(id = R.string.videochat_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } - // Record (mic) button: filled with a mesh gradient while recording is active, - // otherwise a plain primary-tinted mic icon. - IconButton( - onClick = onRecordingClick, - modifier = Modifier - .size(48.dp) - .then( - if (recordingActive) { - Modifier - .clip(CircleShape) - .paint(recordButtonPainter, contentScale = ContentScale.FillBounds) - } else { - Modifier - }, - ), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_mic), - contentDescription = stringResource(id = R.string.record_message), - tint = if (recordingActive) Color.White else iconTint, - modifier = Modifier.size(24.dp), - ) - } + // Record (mic) button: filled with a mesh gradient while recording is active, + // otherwise a plain primary-tinted mic icon. + IconButton( + onClick = onRecordingClick, + modifier = Modifier + .size(48.dp) + .then( + if (recordingActive) { + Modifier + .clip(CircleShape) + .paint(recordButtonPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_mic), + contentDescription = stringResource(id = R.string.record_message), + tint = if (recordingActive) Color.White else iconTint, + modifier = Modifier.size(24.dp), + ) + } - // Add / attachment - IconButton( - onClick = onAddClick, - modifier = Modifier.size(48.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_add), - contentDescription = stringResource(id = R.string.add_attachment_desc), - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - } + // Add / attachment + IconButton( + onClick = onAddClick, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add), + contentDescription = stringResource(id = R.string.add_attachment_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) } } @@ -697,6 +696,11 @@ fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) { private const val EMOJI_COLUMNS = 10 +private const val GlowFadeDurationMillis = 600 +private val GlowTopOffset = 58.dp +private const val GlowScaleX = 1.38f +private const val GlowScaleY = 2.85f + private val emojis = listOf( "\ud83d\ude00", // Grinning Face "\ud83d\ude01", // Grinning Face With Smiling Eyes From ce094ecd424886a061eef5ca1a53b7ce0c4aa36c Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 30 Sep 2026 20:52:03 +0530 Subject: [PATCH 15/17] addressed comments --- .../compose/jetchat/components/MeshUtils.kt | 38 ++-- .../jetchat/conversation/Conversation.kt | 166 +++++++++--------- .../conversation/ConversationViewModel.kt | 14 +- .../compose/jetchat/conversation/UserInput.kt | 15 +- 4 files changed, 113 insertions(+), 120 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt index ee2accfa0e..9ace18da26 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -67,14 +67,14 @@ fun rememberUserInputGlowMeshGradientPainter(): MeshGradientPainter { return remember { fun orbit(colIdx: Int, rowIdx: Int, ampX: Float, ampY: Float): Offset { val angle1 = phase1 + colIdx * 0.85f + rowIdx * 0.65f - val angle2 = phase2 - colIdx * 0.55f + rowIdx * 0.90f + val angle2 = phase2 - colIdx * 0.55f + rowIdx * 0.9f val dx = ampX * (0.72f * cos(angle1) + 0.28f * sin(angle2)) val dy = ampY * (0.72f * sin(angle1) + 0.28f * cos(angle2)) return Offset(dx, dy) } - val uCols = floatArrayOf(0.00f, 0.12f, 0.27f, 0.56f, 0.78f, 1.00f) - val vRows = floatArrayOf(0.00f, 0.20f, 0.33f, 0.43f, 0.64f, 0.85f, 1.00f) + val uCols = floatArrayOf(0f, 0.12f, 0.27f, 0.56f, 0.78f, 1f) + val vRows = floatArrayOf(0f, 0.2f, 0.33f, 0.43f, 0.64f, 0.85f, 1f) val gridColors = arrayOf( // Row 0: transparent royal-blue boundary (top of the glow). @@ -160,7 +160,7 @@ fun rememberUserInputGlowMeshGradientPainter(): MeshGradientPainter { } val ampY = when (r) { 1, 2 -> 0.028f - 3 -> 0.020f + 3 -> 0.02f 4 -> 0.022f else -> 0.016f } @@ -191,23 +191,23 @@ fun rememberHeartReactionMeshGradientPainter(): MeshGradientPainter { columns = 3, hasBicubicColor = true, ) { - // Row 0 (top edge, y = 0.0f) - setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B)) - setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6)) - setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF)) - setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF)) + // Row 0 (top edge, y = 0f) + setVertex(0, 0, Offset(0f, 0f), Color(0xFFFF6B6B)) + setVertex(0, 1, Offset(0.33f, 0f), Color(0xFFFFD6D6)) + setVertex(0, 2, Offset(0.67f, 0f), Color(0xFFE8C3FF)) + setVertex(0, 3, Offset(1f, 0f), Color(0xFFE396FF)) - // Row 1 (mid, y ~ 0.33f - 0.50f) - setVertex(1, 0, Offset(0.0000f, 0.3300f), Color(0xFFFFAAEA)) - setVertex(1, 1, Offset(0.3300f, 0.3300f), Color(0xFFFF6060)) - setVertex(1, 2, Offset(0.6700f, 0.3300f), Color(0xFFFFBCBC)) - setVertex(1, 3, Offset(1.0000f, 0.5000f), Color(0xFFFF2088)) + // Row 1 (mid, y ~ 0.33f - 0.5f) + setVertex(1, 0, Offset(0f, 0.33f), Color(0xFFFFAAEA)) + setVertex(1, 1, Offset(0.33f, 0.33f), Color(0xFFFF6060)) + setVertex(1, 2, Offset(0.67f, 0.33f), Color(0xFFFFBCBC)) + setVertex(1, 3, Offset(1f, 0.5f), Color(0xFFFF2088)) - // Row 2 (bottom edge, y = 1.0f) - setVertex(2, 0, Offset(0.0000f, 1.0000f), Color(0xFFFFFFFF)) - setVertex(2, 1, Offset(0.3300f, 1.0000f), Color(0xFFD0BEF9)) - setVertex(2, 2, Offset(0.6637f, 1.0000f), Color(0xFFFFA298)) - setVertex(2, 3, Offset(1.0000f, 1.0000f), Color(0xFFFF9BEB)) + // Row 2 (bottom edge, y = 1f) + setVertex(2, 0, Offset(0f, 1f), Color(0xFFFFFFFF)) + setVertex(2, 1, Offset(0.33f, 1f), Color(0xFFD0BEF9)) + setVertex(2, 2, Offset(0.66f, 1f), Color(0xFFFFA298)) + setVertex(2, 3, Offset(1f, 1f), Color(0xFFFF9BEB)) } } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 456f607b69..b4aeedefb7 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -22,10 +22,11 @@ import android.content.ClipDescription import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -40,7 +41,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -440,92 +440,82 @@ fun Message( onVideoClick: (String) -> Unit = {}, onLikeToggled: (messageId: String) -> Unit = {}, ) { - val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 12.dp) else Modifier + val borderColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.tertiary + } - if (isUserMe) { - // Self messages: right-aligned bubble with avatar on the right - Row( - modifier = spaceBetweenAuthors - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.Top, - ) { + val spaceBetweenAuthorsModifier = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier + Column(modifier = spaceBetweenAuthorsModifier.fillMaxWidth()) { + if (!isUserMe && isLastMessageByAuthor) { + AuthorNameTimestamp( + msg = msg, + onAuthorClick = onAuthorClick, + modifier = Modifier.padding(start = 16.dp, bottom = 6.dp), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + if (!isUserMe) { + if (isLastMessageByAuthor) { + AuthorAvatar( + authorImage = msg.authorImage, + authorName = msg.author, + borderColor = borderColor, + onAuthorClick = onAuthorClick, + ) + } else { + Spacer(modifier = Modifier.width(74.dp)) + } + } AuthorAndTextMessage( msg = msg, - isUserMe = true, + isUserMe = isUserMe, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, authorClicked = onAuthorClick, onVideoClick = onVideoClick, onLikeToggled = onLikeToggled, modifier = Modifier - .weight(1f, fill = false) - .padding(start = 32.dp), + .padding( + start = if (isUserMe) 16.dp else 0.dp, + end = if (isUserMe) 0.dp else 16.dp, + ) + .weight(1f), ) - Spacer(modifier = Modifier.width(12.dp)) - if (isLastMessageByAuthor) { - Image( - modifier = Modifier - .clickable(onClick = { onAuthorClick(msg.author) }) - .size(48.dp) - .border(1.5.dp, MaterialTheme.colorScheme.inversePrimary, CircleShape) - .clip(CircleShape), - painter = painterResource(id = msg.authorImage), - contentScale = ContentScale.Crop, - contentDescription = null, - ) - } else { - Spacer(modifier = Modifier.width(48.dp)) - } - } - } else { - // Other user messages: left-aligned avatar + name/timestamp badge header + bubble - Column( - modifier = spaceBetweenAuthors - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - if (isLastMessageByAuthor) { - AuthorNameTimestamp(msg = msg, onAuthorClick = onAuthorClick) - Spacer(modifier = Modifier.height(6.dp)) - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - ) { + if (isUserMe) { if (isLastMessageByAuthor) { - Image( - modifier = Modifier - .clickable(onClick = { onAuthorClick(msg.author) }) - .size(48.dp) - .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape) - .clip(CircleShape), - painter = painterResource(id = msg.authorImage), - contentScale = ContentScale.Crop, - contentDescription = null, + AuthorAvatar( + authorImage = msg.authorImage, + authorName = msg.author, + borderColor = borderColor, + onAuthorClick = onAuthorClick, ) } else { - Spacer(modifier = Modifier.width(48.dp)) + Spacer(modifier = Modifier.width(74.dp)) } - Spacer(modifier = Modifier.width(12.dp)) - AuthorAndTextMessage( - msg = msg, - isUserMe = false, - isFirstMessageByAuthor = isFirstMessageByAuthor, - isLastMessageByAuthor = isLastMessageByAuthor, - authorClicked = onAuthorClick, - onVideoClick = onVideoClick, - onLikeToggled = onLikeToggled, - modifier = Modifier - .weight(1f, fill = false) - .padding(end = 32.dp), - ) } } } } +@Composable +private fun RowScope.AuthorAvatar(authorImage: Int, authorName: String, borderColor: Color, onAuthorClick: (String) -> Unit) { + Image( + modifier = Modifier + .clickable(onClick = { onAuthorClick(authorName) }) + .padding(horizontal = 16.dp) + .size(42.dp) + .border(2.dp, borderColor, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) + .clip(CircleShape) + .align(Alignment.Top), + painter = painterResource(id = authorImage), + contentScale = ContentScale.Crop, + contentDescription = null, + ) +} + @Composable fun AuthorAndTextMessage( msg: Message, @@ -559,12 +549,12 @@ fun AuthorAndTextMessage( } @Composable -private fun AuthorNameTimestamp(msg: Message, onAuthorClick: (String) -> Unit = {}) { +private fun AuthorNameTimestamp(msg: Message, modifier: Modifier = Modifier, onAuthorClick: (String) -> Unit = {}) { // Author name + timestamp pill badge Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(24.dp)) .clickable { onAuthorClick(msg.author) } .semantics(mergeDescendants = true) {}, @@ -632,7 +622,7 @@ fun ChatItemBubble( ) { val isLiked = message.isLiked val haptic = LocalHapticFeedback.current - val heartMeshPainter = rememberHeartReactionMeshGradientPainter() + val heartMeshPainter = if (isLiked) rememberHeartReactionMeshGradientPainter() else null val backgroundBubbleColor = if (isUserMe) { MaterialTheme.colorScheme.inversePrimary @@ -646,12 +636,22 @@ fun ChatItemBubble( null } + val likeTransition = updateTransition(targetState = isLiked, label = "like") + // Inset that reveals the heart mesh around liked media. It is applied inside the fixed-size // image, so liking never changes the bubble size (no jumps in the LazyColumn). - val likedMediaInset by animateDpAsState( - targetValue = if (isLiked) 8.dp else 0.dp, - label = "likedMediaInset", - ) + val likedMediaInset by likeTransition.animateDp(label = "likedMediaInset") { liked -> + if (liked) 8.dp else 0.dp + } + + // Goes 0 -> 1 when the message is liked; the whole text bubble scales up and back down along + // the way. graphicsLayer only affects drawing, so the LazyColumn layout doesn't move. + val likeProgress by likeTransition.animateFloat( + transitionSpec = { tween(durationMillis = 350, easing = FastOutSlowInEasing) }, + label = "likeBounce", + ) { liked -> + if (liked) 1f else 0f + } // pointerInput(Unit) below captures this once, so always call the latest callback. val toggleLiked by rememberUpdatedState { @@ -659,14 +659,6 @@ fun ChatItemBubble( onLikeToggled(message.id) } - // Goes 0 -> 1 when the message is liked; the whole text bubble scales up and back down along - // the way. graphicsLayer only affects drawing, so the LazyColumn layout doesn't move. - val likeProgress by animateFloatAsState( - targetValue = if (isLiked) 1f else 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), - label = "likeBounce", - ) - Column( horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start, ) { @@ -689,7 +681,7 @@ fun ChatItemBubble( }, ) { Box( - modifier = if (isLiked) { + modifier = if (heartMeshPainter != null) { Modifier .background(MaterialTheme.colorScheme.surface) .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) @@ -725,7 +717,7 @@ fun ChatItemBubble( }, ) { Box( - modifier = if (isLiked) { + modifier = if (heartMeshPainter != null) { Modifier .background(MaterialTheme.colorScheme.surface) .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt index c503ff822a..c0cf90ca9e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt @@ -16,6 +16,9 @@ package com.example.compose.jetchat.conversation +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.data.initialMessages @@ -26,11 +29,14 @@ import com.example.compose.jetchat.data.initialMessages */ class ConversationViewModel : ViewModel() { - val uiState = ConversationUiState( - channelName = exampleUiState.channelName, - channelMembers = exampleUiState.channelMembers, - initialMessages = initialMessages, + var uiState by mutableStateOf( + ConversationUiState( + channelName = exampleUiState.channelName, + channelMembers = exampleUiState.channelMembers, + initialMessages = initialMessages, + ), ) + private set fun toggleLike(messageId: String) { uiState.toggleLike(messageId) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 51f90d1c8c..b43d51cea1 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -183,7 +183,7 @@ fun UserInput( // Animated mesh-gradient glow behind the card, shown while recording is active. val glowAlpha by animateFloatAsState( targetValue = if (isRecordingActive) 1f else 0f, - animationSpec = tween(durationMillis = GlowFadeDurationMillis), + animationSpec = tween(durationMillis = 600), label = "glowFade", ) @@ -210,8 +210,8 @@ fun UserInput( .then( if (glowMeshPainter != null) { Modifier.drawBehind { - translate(top = -GlowTopOffset.toPx()) { - scale(scaleX = GlowScaleX, scaleY = GlowScaleY) { + translate(top = -58.dp.toPx()) { + scale(scaleX = 1.38f, scaleY = 2.85f) { with(glowMeshPainter) { draw(size, alpha = glowAlpha) } } } @@ -435,7 +435,7 @@ private fun RowScope.UserInputSelector( onAddClick: () -> Unit = {}, ) { val iconTint = MaterialTheme.colorScheme.primary - val recordButtonPainter = rememberRecordButtonMeshGradientPainter() + val recordButtonPainter = if (recordingActive) rememberRecordButtonMeshGradientPainter() else null // Emoji IconButton( @@ -483,7 +483,7 @@ private fun RowScope.UserInputSelector( modifier = Modifier .size(48.dp) .then( - if (recordingActive) { + if (recordButtonPainter != null) { Modifier .clip(CircleShape) .paint(recordButtonPainter, contentScale = ContentScale.FillBounds) @@ -696,11 +696,6 @@ fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) { private const val EMOJI_COLUMNS = 10 -private const val GlowFadeDurationMillis = 600 -private val GlowTopOffset = 58.dp -private const val GlowScaleX = 1.38f -private const val GlowScaleY = 2.85f - private val emojis = listOf( "\ud83d\ude00", // Grinning Face "\ud83d\ude01", // Grinning Face With Smiling Eyes From 2970062fd0b2c668828e1d2c297b7fe06d62dbdc Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 1 Oct 2026 10:59:40 +0530 Subject: [PATCH 16/17] used animateContentSize for like animation --- .../jetchat/conversation/Conversation.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index b4aeedefb7..d992d2ce2a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -20,9 +20,9 @@ package com.example.compose.jetchat.conversation import android.content.ClipDescription import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -638,12 +638,6 @@ fun ChatItemBubble( val likeTransition = updateTransition(targetState = isLiked, label = "like") - // Inset that reveals the heart mesh around liked media. It is applied inside the fixed-size - // image, so liking never changes the bubble size (no jumps in the LazyColumn). - val likedMediaInset by likeTransition.animateDp(label = "likedMediaInset") { liked -> - if (liked) 8.dp else 0.dp - } - // Goes 0 -> 1 when the message is liked; the whole text bubble scales up and back down along // the way. graphicsLayer only affects drawing, so the LazyColumn layout doesn't move. val likeProgress by likeTransition.animateFloat( @@ -717,21 +711,26 @@ fun ChatItemBubble( }, ) { Box( - modifier = if (heartMeshPainter != null) { - Modifier - .background(MaterialTheme.colorScheme.surface) - .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) - } else { - Modifier - }, + // Animates the 8dp frame that reveals the heart mesh around a liked image. + modifier = Modifier + .animateContentSize() + .then( + if (heartMeshPainter != null) { + Modifier + .background(MaterialTheme.colorScheme.surface) + .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ), ) { Image( painter = painter, contentScale = ContentScale.Crop, modifier = Modifier + .padding(if (isLiked) 8.dp else 0.dp) .sizeIn(maxWidth = 240.dp, maxHeight = 260.dp) .aspectRatio(aspectRatio, matchHeightConstraintsFirst = aspectRatio < 1f) - .padding(likedMediaInset) .clip(bubbleShape), contentDescription = stringResource(id = R.string.attached_image), ) From 748bd6f9117ecad848c67add3939dd1bf8c448c3 Mon Sep 17 00:00:00 2001 From: raystatic Date: Thu, 1 Oct 2026 11:01:54 +0530 Subject: [PATCH 17/17] applied like animation on video thumbnail --- .../jetchat/conversation/Conversation.kt | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index d992d2ce2a..ce17c68d63 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -750,16 +750,32 @@ fun ChatItemBubble( detectTapGestures(onDoubleTap = { toggleLiked() }) }, ) { - VideoThumbnail( - videoUri = videoUri, - onClick = { onVideoClick(videoUri) }, - shape = bubbleShape, + Box( + // Animates the 8dp frame that reveals the heart mesh around a liked video. modifier = Modifier - .widthIn(max = 260.dp) - .fillMaxWidth() - .height(200.dp) - .clip(bubbleShape), - ) + .animateContentSize() + .then( + if (heartMeshPainter != null) { + Modifier + .background(MaterialTheme.colorScheme.surface) + .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ), + ) { + VideoThumbnail( + videoUri = videoUri, + onClick = { onVideoClick(videoUri) }, + shape = bubbleShape, + modifier = Modifier + .padding(if (isLiked) 8.dp else 0.dp) + .widthIn(max = 260.dp) + .fillMaxWidth() + .height(200.dp) + .clip(bubbleShape), + ) + } } }