Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,9 @@ 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.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -16,6 +19,7 @@ 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.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
@@ -25,15 +29,18 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -65,6 +72,7 @@ import com.m57.hermescontrol.theme.SystemMessageColor
|
|||||||
import com.m57.hermescontrol.theme.ToolChipColor
|
import com.m57.hermescontrol.theme.ToolChipColor
|
||||||
import com.m57.hermescontrol.theme.ToolChipColorLight
|
import com.m57.hermescontrol.theme.ToolChipColorLight
|
||||||
import com.m57.hermescontrol.theme.UserBubble
|
import com.m57.hermescontrol.theme.UserBubble
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -124,6 +132,15 @@ private fun UserBubble(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
var showCopyButton by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Auto-dismiss copy button after 4 seconds
|
||||||
|
LaunchedEffect(showCopyButton) {
|
||||||
|
if (showCopyButton) {
|
||||||
|
delay(4000)
|
||||||
|
showCopyButton = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val highlightedText =
|
val highlightedText =
|
||||||
remember(message.content, searchQuery, isCurrentMatch) {
|
remember(message.content, searchQuery, isCurrentMatch) {
|
||||||
@@ -148,45 +165,79 @@ private fun UserBubble(
|
|||||||
MaterialTheme.colorScheme.secondary,
|
MaterialTheme.colorScheme.secondary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Surface(
|
Box {
|
||||||
modifier =
|
Surface(
|
||||||
Modifier
|
modifier =
|
||||||
.widthIn(max = maxWidth)
|
Modifier
|
||||||
.clip(
|
.widthIn(max = maxWidth)
|
||||||
RoundedCornerShape(
|
.clip(
|
||||||
topStart = 16.dp,
|
RoundedCornerShape(
|
||||||
topEnd = 16.dp,
|
topStart = 16.dp,
|
||||||
bottomStart = 16.dp,
|
topEnd = 16.dp,
|
||||||
bottomEnd = 4.dp,
|
bottomStart = 16.dp,
|
||||||
),
|
bottomEnd = 4.dp,
|
||||||
).background(brush = gradientBrush)
|
),
|
||||||
.pointerInput(message.content) {
|
).background(brush = gradientBrush)
|
||||||
detectTapGestures(
|
.pointerInput(message.content) {
|
||||||
onLongPress = {
|
detectTapGestures(
|
||||||
clipboardManager.setText(AnnotatedString(message.content))
|
onLongPress = {
|
||||||
},
|
showCopyButton = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = Color.Transparent,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
text = highlightedText,
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
color = Color.Transparent,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
|
||||||
SelectionContainer {
|
|
||||||
Text(
|
Text(
|
||||||
text = highlightedText,
|
text = formatTimestamp(message.timestamp),
|
||||||
color = Color.White,
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
}
|
||||||
text = formatTimestamp(message.timestamp),
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
// Copy button overlay — top-right of the bubble
|
||||||
style = MaterialTheme.typography.labelSmall,
|
AnimatedVisibility(
|
||||||
modifier =
|
visible = showCopyButton,
|
||||||
Modifier
|
enter = fadeIn() + scaleIn(),
|
||||||
.align(Alignment.End)
|
exit = fadeOut() + scaleOut(),
|
||||||
.padding(top = 4.dp),
|
modifier =
|
||||||
)
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = 8.dp, y = (-8).dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
clipboardManager.setText(AnnotatedString(message.content))
|
||||||
|
showCopyButton = false
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ContentCopy,
|
||||||
|
contentDescription = "Copy",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +255,15 @@ private fun AssistantBubble(
|
|||||||
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
||||||
val textColor = MaterialTheme.colorScheme.onSurface
|
val textColor = MaterialTheme.colorScheme.onSurface
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
var showCopyButton by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Auto-dismiss copy button after 4 seconds
|
||||||
|
LaunchedEffect(showCopyButton) {
|
||||||
|
if (showCopyButton) {
|
||||||
|
delay(4000)
|
||||||
|
showCopyButton = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -212,52 +272,86 @@ private fun AssistantBubble(
|
|||||||
.padding(horizontal = 12.dp, vertical = 3.dp),
|
.padding(horizontal = 12.dp, vertical = 3.dp),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Box {
|
||||||
modifier =
|
Surface(
|
||||||
Modifier
|
modifier =
|
||||||
.widthIn(max = maxWidth)
|
Modifier
|
||||||
.animateContentSize()
|
.widthIn(max = maxWidth)
|
||||||
.clip(
|
.animateContentSize()
|
||||||
RoundedCornerShape(
|
.clip(
|
||||||
topStart = 4.dp,
|
RoundedCornerShape(
|
||||||
topEnd = 16.dp,
|
topStart = 4.dp,
|
||||||
bottomStart = 16.dp,
|
topEnd = 16.dp,
|
||||||
bottomEnd = 16.dp,
|
bottomStart = 16.dp,
|
||||||
),
|
bottomEnd = 16.dp,
|
||||||
).pointerInput(message.content) {
|
),
|
||||||
detectTapGestures(
|
).pointerInput(message.content) {
|
||||||
onLongPress = {
|
detectTapGestures(
|
||||||
clipboardManager.setText(AnnotatedString(message.content))
|
onLongPress = {
|
||||||
},
|
showCopyButton = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = bubbleColor,
|
||||||
|
border =
|
||||||
|
BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f),
|
||||||
|
),
|
||||||
|
tonalElevation = 1.dp,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
SelectionContainer {
|
||||||
|
RichText(
|
||||||
|
text = message.content,
|
||||||
|
textColor = textColor,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
color = bubbleColor,
|
if (!message.isStreaming) {
|
||||||
border =
|
Text(
|
||||||
BorderStroke(
|
text = formatTimestamp(message.timestamp),
|
||||||
width = 1.dp,
|
color = textColor.copy(alpha = 0.5f),
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
),
|
modifier =
|
||||||
tonalElevation = 1.dp,
|
Modifier
|
||||||
) {
|
.align(Alignment.End)
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
.padding(top = 4.dp),
|
||||||
SelectionContainer {
|
)
|
||||||
RichText(
|
}
|
||||||
text = message.content,
|
|
||||||
textColor = textColor,
|
|
||||||
searchQuery = searchQuery,
|
|
||||||
isCurrentMatch = isCurrentMatch,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (!message.isStreaming) {
|
}
|
||||||
Text(
|
|
||||||
text = formatTimestamp(message.timestamp),
|
// Copy button overlay — top-right of the bubble
|
||||||
color = textColor.copy(alpha = 0.5f),
|
AnimatedVisibility(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
visible = showCopyButton,
|
||||||
modifier =
|
enter = fadeIn() + scaleIn(),
|
||||||
Modifier
|
exit = fadeOut() + scaleOut(),
|
||||||
.align(Alignment.End)
|
modifier =
|
||||||
.padding(top = 4.dp),
|
Modifier
|
||||||
)
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = (-8).dp, y = (-8).dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
clipboardManager.setText(AnnotatedString(message.content))
|
||||||
|
showCopyButton = false
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ContentCopy,
|
||||||
|
contentDescription = "Copy",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user