fix: move bubble copy button into footer next to timestamp (#850)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
2980a1e884
commit
5a84f9f44e
1 file changed
+72
-107
@@ -8,9 +8,6 @@ import androidx.compose.animation.core.Spring
|
|||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -63,7 +59,6 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
@@ -174,13 +169,13 @@ private fun UserBubble(
|
|||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showCopyButton by remember { mutableStateOf(false) }
|
var copied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Auto-dismiss copy button after 4 seconds
|
// Copy feedback: briefly show ✓ then revert
|
||||||
LaunchedEffect(showCopyButton) {
|
LaunchedEffect(copied) {
|
||||||
if (showCopyButton) {
|
if (copied) {
|
||||||
delay(4000)
|
delay(1500)
|
||||||
showCopyButton = false
|
copied = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,13 +239,7 @@ private fun UserBubble(
|
|||||||
bottomEnd = 4.dp,
|
bottomEnd = 4.dp,
|
||||||
),
|
),
|
||||||
).background(brush = gradientBrush)
|
).background(brush = gradientBrush)
|
||||||
.testTag("chat_bubble_user")
|
.testTag("chat_bubble_user"),
|
||||||
.clickable(
|
|
||||||
role = Role.Button,
|
|
||||||
onClick = {
|
|
||||||
showCopyButton = true
|
|
||||||
},
|
|
||||||
),
|
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
@@ -279,49 +268,40 @@ private fun UserBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
Text(
|
Row(
|
||||||
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
|
|
||||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.End)
|
.align(Alignment.End)
|
||||||
.padding(top = 4.dp),
|
.padding(top = 4.dp),
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
) {
|
||||||
}
|
IconButton(
|
||||||
}
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
// Copy button overlay — top-right of the bubble
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
||||||
AnimatedVisibility(
|
}
|
||||||
visible = showCopyButton,
|
copied = true
|
||||||
enter = fadeIn() + scaleIn(),
|
},
|
||||||
exit = fadeOut() + scaleOut(),
|
modifier = Modifier.size(20.dp),
|
||||||
modifier =
|
) {
|
||||||
Modifier
|
Icon(
|
||||||
.align(Alignment.TopEnd)
|
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||||
.offset(x = 8.dp, y = (-8).dp),
|
contentDescription = stringResource(R.string.content_desc_copy),
|
||||||
) {
|
modifier = Modifier.size(12.dp),
|
||||||
Surface(
|
tint = userBubbleTextColor.copy(alpha = 0.7f),
|
||||||
shape = RoundedCornerShape(50),
|
)
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
|
||||||
shadowElevation = 6.dp,
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
|
||||||
}
|
}
|
||||||
showCopyButton = false
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
},
|
Text(
|
||||||
modifier = Modifier.size(32.dp),
|
text =
|
||||||
) {
|
formatTimestamp(
|
||||||
Icon(
|
message.timestamp,
|
||||||
Icons.Filled.ContentCopy,
|
DateFormat.is24HourFormat(LocalContext.current),
|
||||||
contentDescription = stringResource(R.string.content_desc_copy),
|
),
|
||||||
modifier = Modifier.size(16.dp),
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,13 +327,13 @@ private fun AssistantBubble(
|
|||||||
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showCopyButton by remember { mutableStateOf(false) }
|
var copied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Auto-dismiss copy button after 4 seconds
|
// Copy feedback: briefly show ✓ then revert
|
||||||
LaunchedEffect(showCopyButton) {
|
LaunchedEffect(copied) {
|
||||||
if (showCopyButton) {
|
if (copied) {
|
||||||
delay(4000)
|
delay(1500)
|
||||||
showCopyButton = false
|
copied = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,13 +357,7 @@ private fun AssistantBubble(
|
|||||||
bottomStart = 16.dp,
|
bottomStart = 16.dp,
|
||||||
bottomEnd = 16.dp,
|
bottomEnd = 16.dp,
|
||||||
),
|
),
|
||||||
).testTag("chat_bubble_assistant")
|
).testTag("chat_bubble_assistant"),
|
||||||
.clickable(
|
|
||||||
role = Role.Button,
|
|
||||||
onClick = {
|
|
||||||
showCopyButton = true
|
|
||||||
},
|
|
||||||
),
|
|
||||||
color = bubbleColor,
|
color = bubbleColor,
|
||||||
border =
|
border =
|
||||||
BorderStroke(
|
BorderStroke(
|
||||||
@@ -428,49 +402,40 @@ private fun AssistantBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
Text(
|
Row(
|
||||||
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
|
|
||||||
color = textColor.copy(alpha = 0.5f),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.End)
|
.align(Alignment.End)
|
||||||
.padding(top = 4.dp),
|
.padding(top = 4.dp),
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
) {
|
||||||
}
|
IconButton(
|
||||||
}
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
// Copy button overlay — top-right of the bubble
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
||||||
AnimatedVisibility(
|
}
|
||||||
visible = showCopyButton,
|
copied = true
|
||||||
enter = fadeIn() + scaleIn(),
|
},
|
||||||
exit = fadeOut() + scaleOut(),
|
modifier = Modifier.size(20.dp),
|
||||||
modifier =
|
) {
|
||||||
Modifier
|
Icon(
|
||||||
.align(Alignment.TopEnd)
|
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||||
.offset(x = (-8).dp, y = (-8).dp),
|
contentDescription = stringResource(R.string.content_desc_copy),
|
||||||
) {
|
modifier = Modifier.size(12.dp),
|
||||||
Surface(
|
tint = textColor.copy(alpha = 0.6f),
|
||||||
shape = RoundedCornerShape(50),
|
)
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
|
||||||
shadowElevation = 6.dp,
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
|
||||||
}
|
}
|
||||||
showCopyButton = false
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
},
|
Text(
|
||||||
modifier = Modifier.size(32.dp),
|
text =
|
||||||
) {
|
formatTimestamp(
|
||||||
Icon(
|
message.timestamp,
|
||||||
Icons.Filled.ContentCopy,
|
DateFormat.is24HourFormat(LocalContext.current),
|
||||||
contentDescription = stringResource(R.string.content_desc_copy),
|
),
|
||||||
modifier = Modifier.size(16.dp),
|
color = textColor.copy(alpha = 0.5f),
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user