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..9ace18da26 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/MeshUtils.kt @@ -0,0 +1,246 @@ +/* + * 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.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", + ) + + 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.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(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). + 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.02f + 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]) + } + } + } + } +} + +/** + * Heart reaction mesh gradient for liked chat speech bubbles. + * 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 = 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.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 = 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)) + } + } +} + +/** + * 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 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), + 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/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 6dff34079f..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 @@ -20,15 +20,27 @@ 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.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 +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 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -45,8 +57,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.sizeIn import androidx.compose.foundation.layout.width @@ -56,11 +68,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 @@ -75,6 +85,7 @@ import androidx.compose.runtime.getValue 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 @@ -84,29 +95,35 @@ 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.graphics.Brush +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 +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.LocalUriHandler +import androidx.compose.ui.platform.LocalHapticFeedback 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.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 import com.example.compose.jetchat.video.VideoThumbnail +import kotlin.math.PI +import kotlin.math.sin import kotlinx.coroutines.launch /** @@ -116,6 +133,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 @@ -124,6 +142,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) @@ -184,6 +203,7 @@ fun ConversationContent( Box(modifier = modifier.fillMaxSize()) { Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { ChannelNameBar( channelName = uiState.channelName, @@ -219,6 +239,7 @@ fun ConversationContent( scrollState = scrollState, contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()), onVideoClick = { videoUri -> activeVideoUri = videoUri }, + onMessageLikeToggled = onMessageLikeToggled, ) UserInput( onMessageSent = { content -> @@ -279,7 +300,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + tint = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f), elevation = 0.dp, radius = 12.dp, ), @@ -291,6 +312,7 @@ fun ChannelNameBar( Text( text = channelName, style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, ) // Number of members Text( @@ -335,6 +357,7 @@ fun Messages( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), onVideoClick: (String) -> Unit = {}, + onMessageLikeToggled: (messageId: String) -> Unit = {}, ) { val scope = rememberCoroutineScope() Box(modifier = modifier) { @@ -374,6 +397,7 @@ fun Messages( isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, onVideoClick = onVideoClick, + onLikeToggled = onMessageLikeToggled, ) } } @@ -414,6 +438,7 @@ fun Message( isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, onVideoClick: (String) -> Unit = {}, + onLikeToggled: (messageId: String) -> Unit = {}, ) { val borderColor = if (isUserMe) { MaterialTheme.colorScheme.primary @@ -421,44 +446,54 @@ fun Message( MaterialTheme.colorScheme.tertiary } - val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier - Row(modifier = spaceBetweenAuthors.fillMaxWidth()) { - if (!isUserMe) { - if (isLastMessageByAuthor) { - AuthorAvatar( - authorImage = msg.authorImage, - authorName = msg.author, - borderColor = borderColor, - onAuthorClick = onAuthorClick, - ) - } else { - Spacer(modifier = Modifier.width(74.dp)) - } + 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), + ) } - AuthorAndTextMessage( - msg = msg, - isUserMe = isUserMe, - isFirstMessageByAuthor = isFirstMessageByAuthor, - isLastMessageByAuthor = isLastMessageByAuthor, - authorClicked = onAuthorClick, - onVideoClick = onVideoClick, - modifier = Modifier - .padding( - start = if (isUserMe) 16.dp else 0.dp, - end = if (isUserMe) 0.dp else 16.dp, - ) - .weight(1f), - ) - if (isUserMe) { - if (isLastMessageByAuthor) { - AuthorAvatar( - authorImage = msg.authorImage, - authorName = msg.author, - borderColor = borderColor, - onAuthorClick = onAuthorClick, - ) - } else { - Spacer(modifier = Modifier.width(74.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 = isUserMe, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + authorClicked = onAuthorClick, + onVideoClick = onVideoClick, + onLikeToggled = onLikeToggled, + modifier = Modifier + .padding( + start = if (isUserMe) 16.dp else 0.dp, + end = if (isUserMe) 0.dp else 16.dp, + ) + .weight(1f), + ) + if (isUserMe) { + if (isLastMessageByAuthor) { + AuthorAvatar( + authorImage = msg.authorImage, + authorName = msg.author, + borderColor = borderColor, + onAuthorClick = onAuthorClick, + ) + } else { + Spacer(modifier = Modifier.width(74.dp)) + } } } } @@ -471,7 +506,7 @@ private fun RowScope.AuthorAvatar(authorImage: Int, authorName: String, borderCo .clickable(onClick = { onAuthorClick(authorName) }) .padding(horizontal = 16.dp) .size(42.dp) - .border(1.5.dp, borderColor, CircleShape) + .border(2.dp, borderColor, CircleShape) .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) .clip(CircleShape) .align(Alignment.Top), @@ -490,19 +525,18 @@ fun AuthorAndTextMessage( authorClicked: (String) -> Unit, modifier: Modifier = Modifier, onVideoClick: (String) -> Unit = {}, + onLikeToggled: (messageId: String) -> Unit = {}, ) { Column( modifier = modifier, horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start, ) { - if (isLastMessageByAuthor) { - AuthorNameTimestamp(msg) - } ChatItemBubble( message = msg, isUserMe = isUserMe, authorClicked = authorClicked, onVideoClick = onVideoClick, + onLikeToggled = onLikeToggled, ) if (isFirstMessageByAuthor) { // Last bubble before next author @@ -515,23 +549,32 @@ 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, modifier: Modifier = Modifier, onAuthorClick: (String) -> Unit = {}) { + // Author name + timestamp pill badge + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + 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 = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = msg.timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } } } @@ -542,15 +585,18 @@ private val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp) 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 = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, ) DayHeaderLine() } @@ -562,31 +608,86 @@ private fun RowScope.DayHeaderLine() { modifier = Modifier .weight(1f) .align(Alignment.CenterVertically), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + color = MaterialTheme.colorScheme.outline.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 = {}, + onLikeToggled: (messageId: String) -> Unit = {}, +) { + val isLiked = message.isLiked + val haptic = LocalHapticFeedback.current + val heartMeshPainter = if (isLiked) rememberHeartReactionMeshGradientPainter() else null + val backgroundBubbleColor = if (isUserMe) { - MaterialTheme.colorScheme.primary + MaterialTheme.colorScheme.inversePrimary } else { - MaterialTheme.colorScheme.surfaceVariant + MaterialTheme.colorScheme.primaryContainer } val bubbleShape = if (isUserMe) ChatBubbleShapeMe else ChatBubbleShapeOthers + val bubbleBorder = if (isLiked) { + BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface) + } else { + null + } - Column(horizontalAlignment = if (isUserMe) Alignment.End else Alignment.Start) { + val likeTransition = updateTransition(targetState = isLiked, label = "like") + + // 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 { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLikeToggled(message.id) + } + + 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, + color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, + border = bubbleBorder, + 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() }) + }, ) { - ClickableMessage( - message = message, - isUserMe = isUserMe, - authorClicked = authorClicked, - ) + Box( + modifier = if (heartMeshPainter != null) { + Modifier + .background(MaterialTheme.colorScheme.surface) + .paint(heartMeshPainter, contentScale = ContentScale.FillBounds) + } else { + Modifier + }, + ) { + ClickableMessage( + message = message, + authorClicked = authorClicked, + ) + } } } @@ -602,18 +703,38 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) 1f } Surface( - color = backgroundBubbleColor, + color = if (isLiked) Color.Transparent else backgroundBubbleColor, shape = bubbleShape, + border = bubbleBorder, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onDoubleTap = { toggleLiked() }) + }, ) { - Image( - painter = painter, - contentScale = ContentScale.Crop, + Box( + // Animates the 8dp frame that reveals the heart mesh around a liked image. modifier = Modifier - .sizeIn(maxWidth = 240.dp, maxHeight = 260.dp) - .aspectRatio(aspectRatio, matchHeightConstraintsFirst = aspectRatio < 1f) - .clip(bubbleShape), - contentDescription = stringResource(id = R.string.attached_image), - ) + .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) + .clip(bubbleShape), + contentDescription = stringResource(id = R.string.attached_image), + ) + } } } @@ -622,52 +743,89 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) 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, - 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), + ) + } } } + + // Liked reaction badge attached to bubble corner + this@Column.AnimatedVisibility( + visible = isLiked, + + 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) + .padding(end = 4.dp), + ) { + 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) { - val uriHandler = LocalUriHandler.current - +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( text = message.content, - primary = isUserMe, + primary = false, + onPersonClick = authorClicked, ) - ClickableText( + Text( text = styledMessage, - style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), - modifier = Modifier.padding(16.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 - } - } - }, + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } +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..c0cf90ca9e --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationViewModel.kt @@ -0,0 +1,44 @@ +/* + * 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.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 + +/** + * Holds the conversation state so it survives configuration changes, and handles message + * actions such as liking a message. + */ +class ConversationViewModel : ViewModel() { + + 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/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 a61148c79d..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 @@ -20,14 +20,9 @@ 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 -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically @@ -35,7 +30,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,16 +39,14 @@ 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.RowScope 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 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,20 +54,17 @@ 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 -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.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -84,16 +73,19 @@ 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 import androidx.compose.ui.focus.focusRequester 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 @@ -110,12 +102,9 @@ 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.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, @@ -167,6 +156,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) { @@ -176,6 +169,7 @@ fun UserInput( onMessageSent(textState.text) } textState = TextFieldValue() + isRecordingActive = false resetScroll() dismissKeyboard() } @@ -183,53 +177,146 @@ 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(), + val surfaceColor = MaterialTheme.colorScheme.surfaceContainer + val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null + + // 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 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(32.dp) + + Surface( + shape = cardShape, + color = surfaceColor, + 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) } + } + } + } + } else { + Modifier + }, + ) + .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(), + verticalArrangement = Arrangement.SpaceBetween, ) { - attachedVideoUri?.let { videoUri -> - AttachedVideoPreview( - videoUri = videoUri, - onRemove = { attachedVideoUri = null }, + 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, ) } - } - 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() + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + UserInputSelector( + onSelectorChange = { currentInputSelector = it }, + currentInputSelector = currentInputSelector, + recordingActive = isRecordingActive, + onRecordingClick = { isRecordingActive = !isRecordingActive }, + onVideoClick = { + currentInputSelector = InputSelector.NONE + videoPickerLauncher.launch("video/*") + }, + onAddClick = { currentInputSelector = InputSelector.MAP }, + ) + + IconButton( + onClick = sendMessage, + enabled = sendMessageEnabled, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = stringResource(id = R.string.send), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (sendMessageEnabled) 0.85f else 0.54f, + ), + modifier = Modifier.size(24.dp), + ) } - 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, - ) + } + } } + + SelectorExpanded( + onCloseRequested = dismissKeyboard, + onTextAdded = { textState = textState.addText(it) }, + currentSelector = currentInputSelector, + ) } } @@ -339,118 +426,90 @@ fun FunctionalityNotAvailablePanel() { } @Composable -private fun UserInputSelector( +private fun RowScope.UserInputSelector( onSelectorChange: (InputSelector) -> Unit, - sendMessageEnabled: Boolean, - onMessageSent: () -> Unit, currentInputSelector: InputSelector, - modifier: Modifier = Modifier, + recordingActive: Boolean, + onRecordingClick: () -> Unit, onVideoClick: () -> Unit = {}, + onAddClick: () -> Unit = {}, ) { - Row( - modifier = modifier - .height(72.dp) - .wrapContentHeight() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, + val iconTint = MaterialTheme.colorScheme.primary + val recordButtonPainter = if (recordingActive) rememberRecordButtonMeshGradientPainter() else null + + // Emoji + IconButton( + onClick = { onSelectorChange(InputSelector.EMOJI) }, + modifier = Modifier.size(48.dp), ) { - InputSelectorButton( - 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), + 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), - ) - } else { - null - } - Spacer(modifier = Modifier.weight(1f)) - - val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - - val buttonColors = ButtonDefaults.buttonColors( - disabledContainerColor = Color.Transparent, - disabledContentColor = disabledContentColor, + // 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), ) + } - // Send button - Button( - modifier = Modifier.height(36.dp), - enabled = sendMessageEnabled, - onClick = onMessageSent, - colors = buttonColors, - border = border, - contentPadding = PaddingValues(0.dp), - ) { - Text( - stringResource(id = R.string.send), - modifier = Modifier.padding(horizontal = 16.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), + ) } -} -@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), + // 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 (recordButtonPainter != null) { + 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), ) - } else { - Modifier } + + // Add / attachment IconButton( - onClick = onClick, - modifier = modifier.then(backgroundModifier), + onClick = onAddClick, + modifier = Modifier.size(48.dp), ) { - 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, + painter = painterResource(id = R.drawable.ic_add), + contentDescription = stringResource(id = R.string.add_attachment_desc), + tint = iconTint, + modifier = Modifier.size(24.dp), ) } } @@ -463,7 +522,6 @@ private fun NotAvailablePopup(onDismissed: () -> Unit) { val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey -@OptIn(ExperimentalAnimationApi::class) @ExperimentalFoundationApi @Composable private fun UserInputText( @@ -475,58 +533,26 @@ 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( + Box( modifier = Modifier .fillMaxWidth() - .height(64.dp), - horizontalArrangement = Arrangement.End, + .padding(start = 24.dp, top = 20.dp, end = 32.dp) + .heightIn(min = 48.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 + }, ) } } @@ -542,12 +568,12 @@ private fun BoxScope.UserInputTextField( modifier: Modifier = Modifier, ) { var lastFocusState by remember { mutableStateOf(false) } + 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) @@ -561,86 +587,20 @@ 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, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.primary, + ), ) - 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), - ) - } -} - -@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(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - 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, - ) - } } } 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 5b8709fd0a..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 @@ -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( 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/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 68aa3e2ada..1af056790b 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -34,8 +34,7 @@ me 8:30 PM %d members - Message #composers - ◀ Swipe to cancel + Send a message Emojis Stickers @@ -65,8 +64,8 @@ Search Information More options - Touch and hold to record Record voice message + Add attachment JetChat unread messages Add Widget to Home Page