From c8cbb84baa3026a6f5974544efb9fd468e2f3118 Mon Sep 17 00:00:00 2001 From: raystatic Date: Wed, 16 Sep 2026 20:58:42 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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), )