Refactor Chat Detail Screen components and models for better organization
- Added reusable UI components for the Chat Detail Screen in ChatDetailComponents.kt, including DateHeader, TypingIndicator, MessageBubble, and more. - Introduced MessageInputBar in ChatDetailInput.kt to handle message input and emoji picker functionality. - Created data models for chat messages and replies in ChatDetailModels.kt, including ChatMessage and ReplyData. - Implemented utility functions for chat details in ChatDetailUtils.kt, such as date formatting and avatar color generation.
This commit is contained in:
@@ -687,7 +687,6 @@ fun MainScreen(
|
||||
user = selectedUser!!,
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
currentUserPrivateKey = accountPrivateKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = { selectedUser = null },
|
||||
onUserProfileClick = { user ->
|
||||
// Открываем профиль другого пользователя
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import com.rosetta.messenger.database.DialogEntity
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.ui.chats.utils.*
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Reusable UI components for Chat Detail Screen
|
||||
* Extracted from ChatDetailScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Date header with fade-in animation */
|
||||
@Composable
|
||||
fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (isVisible) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
|
||||
label = "dateAlpha"
|
||||
)
|
||||
|
||||
LaunchedEffect(dateText) { isVisible = true }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
.graphicsLayer { this.alpha = alpha },
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = dateText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = secondaryTextColor,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = secondaryTextColor.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Typing indicator with animated dots (Telegram style) */
|
||||
@Composable
|
||||
fun TypingIndicator(isDarkTheme: Boolean) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||
val typingColor = Color(0xFF54A9EB)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(text = "typing", fontSize = 13.sp, color = typingColor)
|
||||
|
||||
repeat(3) { index ->
|
||||
val offsetY by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = -4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 600,
|
||||
delayMillis = index * 100,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "dot$index"
|
||||
)
|
||||
|
||||
Text(
|
||||
text = ".",
|
||||
fontSize = 13.sp,
|
||||
color = typingColor,
|
||||
modifier = Modifier.offset(y = offsetY.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Message bubble with Telegram-style design and animations */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: ChatMessage,
|
||||
isDarkTheme: Boolean,
|
||||
showTail: Boolean = true,
|
||||
isSelected: Boolean = false,
|
||||
isHighlighted: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
onLongClick: () -> Unit = {},
|
||||
onClick: () -> Unit = {},
|
||||
onSwipeToReply: () -> Unit = {},
|
||||
onReplyClick: (String) -> Unit = {},
|
||||
onRetry: () -> Unit = {},
|
||||
onDelete: () -> Unit = {}
|
||||
) {
|
||||
// Swipe-to-reply state
|
||||
var swipeOffset by remember { mutableStateOf(0f) }
|
||||
val swipeThreshold = 80f
|
||||
val maxSwipe = 120f
|
||||
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = swipeOffset,
|
||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
|
||||
label = "swipeOffset"
|
||||
)
|
||||
|
||||
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
|
||||
|
||||
// Selection animations
|
||||
val selectionScale by animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.95f else 1f,
|
||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
|
||||
label = "selectionScale"
|
||||
)
|
||||
val selectionAlpha by animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.85f else 1f,
|
||||
animationSpec = tween(150),
|
||||
label = "selectionAlpha"
|
||||
)
|
||||
|
||||
// Colors
|
||||
val bubbleColor = remember(message.isOutgoing, isDarkTheme) {
|
||||
if (message.isOutgoing) {
|
||||
PrimaryBlue
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||
}
|
||||
}
|
||||
|
||||
val textColor = remember(message.isOutgoing, isDarkTheme) {
|
||||
if (message.isOutgoing) Color.White
|
||||
else if (isDarkTheme) Color.White else Color(0xFF000000)
|
||||
}
|
||||
|
||||
val timeColor = remember(message.isOutgoing, isDarkTheme) {
|
||||
if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
|
||||
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
}
|
||||
|
||||
val bubbleShape = remember(message.isOutgoing, showTail) {
|
||||
RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp,
|
||||
bottomStart = if (message.isOutgoing) 16.dp else (if (showTail) 4.dp else 16.dp),
|
||||
bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 16.dp) else 16.dp
|
||||
)
|
||||
}
|
||||
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (swipeOffset <= -swipeThreshold) {
|
||||
onSwipeToReply()
|
||||
}
|
||||
swipeOffset = 0f
|
||||
},
|
||||
onDragCancel = { swipeOffset = 0f },
|
||||
onHorizontalDrag = { _, dragAmount ->
|
||||
val newOffset = swipeOffset + dragAmount
|
||||
swipeOffset = newOffset.coerceIn(-maxSwipe, 0f)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Reply icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.graphicsLayer {
|
||||
alpha = swipeProgress
|
||||
scaleX = swipeProgress
|
||||
scaleY = swipeProgress
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (swipeProgress >= 1f) PrimaryBlue
|
||||
else if (isDarkTheme) Color(0xFF3A3A3A)
|
||||
else Color(0xFFE0E0E0)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Reply,
|
||||
contentDescription = "Reply",
|
||||
tint = if (swipeProgress >= 1f) Color.White
|
||||
else if (isDarkTheme) Color.White.copy(alpha = 0.7f)
|
||||
else Color(0xFF666666),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val selectionBackgroundColor by animateColorAsState(
|
||||
targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent,
|
||||
animationSpec = tween(200),
|
||||
label = "selectionBg"
|
||||
)
|
||||
|
||||
val highlightBackgroundColor by animateColorAsState(
|
||||
targetValue = if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) else Color.Transparent,
|
||||
animationSpec = tween(300),
|
||||
label = "highlightBg"
|
||||
)
|
||||
|
||||
val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(combinedBackgroundColor)
|
||||
.padding(vertical = 2.dp)
|
||||
.offset { IntOffset(animatedOffset.toInt(), 0) },
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Selection checkmark
|
||||
AnimatedVisibility(
|
||||
visible = isSelected,
|
||||
enter = fadeIn(tween(150)) + scaleIn(
|
||||
initialScale = 0.3f,
|
||||
animationSpec = spring(dampingRatio = 0.6f)
|
||||
),
|
||||
exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, end = 4.dp)
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CD964)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !isSelected,
|
||||
enter = fadeIn(tween(100)),
|
||||
exit = fadeOut(tween(100))
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
if (message.isOutgoing) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
.widthIn(max = 280.dp)
|
||||
.graphicsLayer {
|
||||
this.alpha = selectionAlpha
|
||||
this.scaleX = selectionScale
|
||||
this.scaleY = selectionScale
|
||||
}
|
||||
.combinedClickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.clip(bubbleShape)
|
||||
.background(bubbleColor)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
message.replyData?.let { reply ->
|
||||
ReplyBubble(
|
||||
replyData = reply,
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = { onReplyClick(reply.messageId) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
modifier = Modifier.weight(1f, fill = true)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(message.timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status
|
||||
AnimatedMessageStatus(
|
||||
status = displayStatus,
|
||||
timeColor = timeColor,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Animated message status indicator */
|
||||
@Composable
|
||||
fun AnimatedMessageStatus(
|
||||
status: MessageStatus,
|
||||
timeColor: Color,
|
||||
timestamp: Long = 0L,
|
||||
onRetry: () -> Unit = {},
|
||||
onDelete: () -> Unit = {}
|
||||
) {
|
||||
val isTimedOut = status == MessageStatus.SENDING &&
|
||||
timestamp > 0 &&
|
||||
!isMessageDeliveredByTime(timestamp)
|
||||
val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status
|
||||
|
||||
val targetColor = when (effectiveStatus) {
|
||||
MessageStatus.READ -> Color(0xFF4FC3F7)
|
||||
MessageStatus.ERROR -> Color(0xFFE53935)
|
||||
else -> timeColor
|
||||
}
|
||||
|
||||
val animatedColor by animateColorAsState(
|
||||
targetValue = targetColor,
|
||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||
label = "statusColor"
|
||||
)
|
||||
|
||||
var previousStatus by remember { mutableStateOf(effectiveStatus) }
|
||||
var shouldAnimate by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(effectiveStatus) {
|
||||
if (previousStatus != effectiveStatus) {
|
||||
shouldAnimate = true
|
||||
previousStatus = effectiveStatus
|
||||
}
|
||||
}
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (shouldAnimate) 1.2f else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
),
|
||||
finishedListener = { shouldAnimate = false },
|
||||
label = "statusScale"
|
||||
)
|
||||
|
||||
var showErrorMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Crossfade(
|
||||
targetState = effectiveStatus,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "statusIcon"
|
||||
) { currentStatus ->
|
||||
val iconSize = with(LocalDensity.current) { 14.sp.toDp() }
|
||||
|
||||
Icon(
|
||||
imageVector = when (currentStatus) {
|
||||
MessageStatus.SENDING -> Icons.Default.Schedule
|
||||
MessageStatus.SENT -> Icons.Default.Done
|
||||
MessageStatus.DELIVERED -> Icons.Default.Done
|
||||
MessageStatus.READ -> Icons.Default.DoneAll
|
||||
MessageStatus.ERROR -> Icons.Default.Error
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.scale(scale)
|
||||
.then(
|
||||
if (currentStatus == MessageStatus.ERROR) {
|
||||
Modifier.clickable { showErrorMenu = true }
|
||||
} else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showErrorMenu,
|
||||
onDismissRequest = { showErrorMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Retry") },
|
||||
onClick = {
|
||||
showErrorMenu = false
|
||||
onRetry()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete", color = Color(0xFFE53935)) },
|
||||
onClick = {
|
||||
showErrorMenu = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE53935),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reply bubble inside message */
|
||||
@Composable
|
||||
fun ReplyBubble(
|
||||
replyData: ReplyData,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
val backgroundColor = if (isOutgoing) {
|
||||
Color.Black.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.08f)
|
||||
}
|
||||
|
||||
val borderColor = if (isOutgoing) Color.White else PrimaryBlue
|
||||
val nameColor = if (isOutgoing) Color.White else PrimaryBlue
|
||||
val replyTextColor = if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.85f)
|
||||
} else {
|
||||
if (isDarkTheme) Color.White else Color.Black
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName,
|
||||
color = nameColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = replyData.text.ifEmpty { "..." },
|
||||
color = replyTextColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Message skeleton loader with shimmer animation */
|
||||
@Composable
|
||||
fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
|
||||
val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
|
||||
val shimmerAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.4f,
|
||||
targetValue = 0.8f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(800, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "shimmerAlpha"
|
||||
)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 80.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
SkeletonBubble(true, 0.45f, skeletonColor, shimmerAlpha)
|
||||
SkeletonBubble(false, 0.55f, skeletonColor, shimmerAlpha)
|
||||
SkeletonBubble(true, 0.35f, skeletonColor, shimmerAlpha)
|
||||
SkeletonBubble(false, 0.50f, skeletonColor, shimmerAlpha)
|
||||
SkeletonBubble(true, 0.60f, skeletonColor, shimmerAlpha)
|
||||
SkeletonBubble(false, 0.40f, skeletonColor, shimmerAlpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBubble(
|
||||
isOutgoing: Boolean,
|
||||
widthFraction: Float,
|
||||
bubbleColor: Color,
|
||||
alpha: Float
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(widthFraction)
|
||||
.defaultMinSize(minHeight = 44.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 18.dp,
|
||||
topEnd = 18.dp,
|
||||
bottomStart = if (isOutgoing) 18.dp else 6.dp,
|
||||
bottomEnd = if (isOutgoing) 6.dp else 18.dp
|
||||
)
|
||||
)
|
||||
.background(bubbleColor.copy(alpha = alpha))
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Telegram-style kebab menu */
|
||||
@Composable
|
||||
fun KebabMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
isSavedMessages: Boolean,
|
||||
isBlocked: Boolean,
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.width(220.dp),
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
if (!isSavedMessages) {
|
||||
KebabMenuItem(
|
||||
icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block,
|
||||
text = if (isBlocked) "Unblock User" else "Block User",
|
||||
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
|
||||
tintColor = PrimaryBlue,
|
||||
textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.height(0.5.dp)
|
||||
.background(dividerColor)
|
||||
)
|
||||
}
|
||||
|
||||
KebabMenuItem(
|
||||
icon = Icons.Default.Delete,
|
||||
text = "Delete Chat",
|
||||
onClick = onDeleteClick,
|
||||
tintColor = Color(0xFFFF3B30),
|
||||
textColor = Color(0xFFFF3B30)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KebabMenuItem(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
tintColor: Color,
|
||||
textColor: Color
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tintColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package com.rosetta.messenger.ui.chats.input
|
||||
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.ui.chats.components.*
|
||||
import com.rosetta.messenger.ui.chats.utils.*
|
||||
import com.rosetta.messenger.ui.chats.ChatViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Message input bar and related components
|
||||
* Extracted from ChatDetailScreen.kt for better organization
|
||||
*
|
||||
* Telegram UX rules:
|
||||
* 1. Input always linked to keyboard (imePadding)
|
||||
* 2. Last message always visible
|
||||
* 3. No layout jumps
|
||||
* 4. After sending: input clears, keyboard stays open
|
||||
* 5. Input grows upward for multi-line (up to 6 lines)
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun MessageInputBar(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
placeholderColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
isForwardMode: Boolean = false,
|
||||
onCloseReply: () -> Unit = {},
|
||||
chatTitle: String = "",
|
||||
isBlocked: Boolean = false,
|
||||
showEmojiPicker: Boolean = false,
|
||||
onToggleEmojiPicker: (Boolean) -> Unit = {},
|
||||
focusRequester: FocusRequester? = null,
|
||||
coordinator: KeyboardTransitionCoordinator,
|
||||
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
onReplyClick: (String) -> Unit = {}
|
||||
) {
|
||||
val hasReply = replyMessages.isNotEmpty()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
|
||||
|
||||
// Auto-focus when reply panel opens
|
||||
LaunchedEffect(hasReply, editTextView) {
|
||||
if (hasReply) {
|
||||
delay(50)
|
||||
editTextView?.let { editText ->
|
||||
if (!showEmojiPicker) {
|
||||
editText.requestFocus()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imeInsets = WindowInsets.ime
|
||||
var isKeyboardVisible by remember { mutableStateOf(false) }
|
||||
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
|
||||
var lastToggleTime by remember { mutableLongStateOf(0L) }
|
||||
val toggleCooldownMs = 500L
|
||||
var isKeyboardAnimating by remember { mutableStateOf(false) }
|
||||
var lastKeyboardHeightChange by remember { mutableLongStateOf(0L) }
|
||||
val keyboardAnimationStabilizeMs = 250L
|
||||
|
||||
// Update coordinator through snapshotFlow (no recomposition)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
|
||||
val now = System.currentTimeMillis()
|
||||
val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
|
||||
if (heightChanged && currentImeHeight.value > 0) {
|
||||
lastKeyboardHeightChange = now
|
||||
isKeyboardAnimating = true
|
||||
}
|
||||
if (now - lastKeyboardHeightChange > keyboardAnimationStabilizeMs && isKeyboardAnimating) {
|
||||
isKeyboardAnimating = false
|
||||
}
|
||||
|
||||
isKeyboardVisible = currentImeHeight > 50.dp
|
||||
coordinator.updateKeyboardHeight(currentImeHeight)
|
||||
if (currentImeHeight > 100.dp) {
|
||||
coordinator.syncHeights()
|
||||
lastStableKeyboardHeight = currentImeHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved keyboard height
|
||||
LaunchedEffect(Unit) {
|
||||
com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight(context)
|
||||
}
|
||||
|
||||
// Save keyboard height when stable
|
||||
LaunchedEffect(isKeyboardVisible, showEmojiPicker) {
|
||||
if (isKeyboardVisible && !showEmojiPicker) {
|
||||
delay(350)
|
||||
if (isKeyboardVisible && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) {
|
||||
val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() }
|
||||
com.rosetta.messenger.ui.components.KeyboardHeightProvider.saveKeyboardHeight(context, heightPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
|
||||
// Close keyboard when user is blocked
|
||||
LaunchedEffect(isBlocked) {
|
||||
if (isBlocked) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideKeyboardCompletely() {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
fun toggleEmojiPicker() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastToggle = currentTime - lastToggleTime
|
||||
|
||||
if (timeSinceLastToggle < toggleCooldownMs) {
|
||||
return
|
||||
}
|
||||
|
||||
lastToggleTime = currentTime
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
|
||||
if (coordinator.isEmojiVisible) {
|
||||
// EMOJI → KEYBOARD
|
||||
coordinator.requestShowKeyboard(
|
||||
showKeyboard = {
|
||||
editTextView?.let { editText ->
|
||||
editText.requestFocus()
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
|
||||
}
|
||||
},
|
||||
hideEmoji = { onToggleEmojiPicker(false) }
|
||||
)
|
||||
} else {
|
||||
// KEYBOARD → EMOJI
|
||||
coordinator.requestShowEmoji(
|
||||
hideKeyboard = { imm.hideSoftInputFromWindow(view.windowToken, 0) },
|
||||
showEmoji = { onToggleEmojiPicker(true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSend() {
|
||||
if (value.isNotBlank() || hasReply) {
|
||||
isSending = true
|
||||
onSend()
|
||||
scope.launch {
|
||||
delay(150)
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) {
|
||||
if (isBlocked) {
|
||||
// BLOCKED CHAT FOOTER
|
||||
Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(0.5.dp)
|
||||
.background(
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
|
||||
else Color.Black.copy(alpha = 0.08f)
|
||||
)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Block,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF6B6B),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "You need to unblock user to send messages.",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(0.5.dp)
|
||||
.background(
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
|
||||
else Color.Black.copy(alpha = 0.08f)
|
||||
)
|
||||
)
|
||||
|
||||
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = backgroundColor)
|
||||
.padding(
|
||||
bottom = if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 0.dp else 16.dp
|
||||
)
|
||||
.then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier)
|
||||
) {
|
||||
// REPLY PANEL
|
||||
AnimatedVisibility(
|
||||
visible = hasReply,
|
||||
enter = fadeIn(animationSpec = tween(200, easing = FastOutSlowInEasing)) +
|
||||
expandVertically(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
expandFrom = Alignment.Bottom
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(150, easing = FastOutLinearInEasing)) +
|
||||
shrinkVertically(
|
||||
animationSpec = tween(150, easing = FastOutLinearInEasing),
|
||||
shrinkTowards = Alignment.Bottom
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (displayReplyMessages.isNotEmpty()) {
|
||||
onReplyClick(displayReplyMessages.first().messageId)
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(32.dp)
|
||||
.background(PrimaryBlue, androidx.compose.foundation.shape.RoundedCornerShape(1.5.dp))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isForwardMode)
|
||||
"Forward message${if (displayReplyMessages.size > 1) "s" else ""}"
|
||||
else
|
||||
"Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PrimaryBlue,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
if (displayReplyMessages.isNotEmpty()) {
|
||||
Text(
|
||||
text = if (displayReplyMessages.size == 1) {
|
||||
val msg = displayReplyMessages.first()
|
||||
val shortText = msg.text.take(40)
|
||||
if (shortText.length < msg.text.length) "$shortText..." else shortText
|
||||
} else "${displayReplyMessages.size} messages",
|
||||
fontSize = 13.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onCloseReply
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Cancel",
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f)
|
||||
else Color.Black.copy(alpha = 0.4f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// INPUT ROW
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { /* TODO: Attach file/image */ },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach",
|
||||
tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
|
||||
else Color(0xFF8E8E93).copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 40.dp, max = 150.dp)
|
||||
.background(color = backgroundColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.TopStart
|
||||
) {
|
||||
AppleEmojiTextField(
|
||||
value = value,
|
||||
onValueChange = { newValue -> onValueChange(newValue) },
|
||||
textColor = textColor,
|
||||
textSize = 16f,
|
||||
hint = "Type message...",
|
||||
hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
requestFocus = hasReply,
|
||||
onViewCreated = { view -> editTextView = view },
|
||||
onFocusChanged = { hasFocus ->
|
||||
if (hasFocus && showEmojiPicker) {
|
||||
onToggleEmojiPicker(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { toggleEmojiPicker() },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (showEmojiPicker) Icons.Default.Keyboard
|
||||
else Icons.Default.SentimentSatisfiedAlt,
|
||||
contentDescription = "Emoji",
|
||||
tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
|
||||
else Color(0xFF8E8E93).copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = canSend || isSending,
|
||||
enter = scaleIn(tween(150)) + fadeIn(tween(150)),
|
||||
exit = scaleOut(tween(100)) + fadeOut(tween(100))
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { handleSend() },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TelegramSendIcon,
|
||||
contentDescription = "Send",
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EMOJI PICKER
|
||||
if (!isBlocked) {
|
||||
AnimatedKeyboardTransition(
|
||||
coordinator = coordinator,
|
||||
showEmojiPicker = showEmojiPicker
|
||||
) {
|
||||
OptimizedEmojiPicker(
|
||||
isVisible = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onEmojiSelected = { emoji -> onValueChange(value + emoji) },
|
||||
onClose = { toggleEmojiPicker() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.rosetta.messenger.ui.chats.models
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.rosetta.messenger.data.Message
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Models and data classes for Chat Detail Screen
|
||||
* Extracted from ChatDetailScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Stable wrapper for SearchUser to optimize recompositions */
|
||||
@Stable
|
||||
class StableSearchUser(val user: SearchUser)
|
||||
|
||||
/** Reply/Forward data inside a message */
|
||||
data class ReplyData(
|
||||
val messageId: String,
|
||||
val senderName: String,
|
||||
val text: String,
|
||||
val isFromMe: Boolean,
|
||||
val isForwarded: Boolean = false
|
||||
)
|
||||
|
||||
/** Legacy message model (for compatibility) */
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val isOutgoing: Boolean,
|
||||
val timestamp: Date,
|
||||
val status: MessageStatus = MessageStatus.SENT,
|
||||
val showDateHeader: Boolean = false,
|
||||
val replyData: ReplyData? = null
|
||||
)
|
||||
|
||||
/** Message delivery and read status */
|
||||
enum class MessageStatus {
|
||||
SENDING,
|
||||
SENT,
|
||||
DELIVERED,
|
||||
READ,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/** Avatar colors for consistent user identification */
|
||||
data class AvatarColors(
|
||||
val backgroundColor: androidx.compose.ui.graphics.Color,
|
||||
val textColor: androidx.compose.ui.graphics.Color
|
||||
)
|
||||
|
||||
// Extension function to convert Message to ChatMessage
|
||||
fun Message.toChatMessage() = ChatMessage(
|
||||
id = messageId,
|
||||
text = content,
|
||||
isOutgoing = isFromMe,
|
||||
timestamp = Date(timestamp),
|
||||
status = when (deliveryStatus) {
|
||||
DeliveryStatus.WAITING -> MessageStatus.SENDING
|
||||
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
|
||||
DeliveryStatus.ERROR -> MessageStatus.SENT
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.rosetta.messenger.ui.chats.utils
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Utility functions and constants for Chat Detail Screen
|
||||
* Extracted from ChatDetailScreen.kt for better organization
|
||||
*/
|
||||
|
||||
// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910)
|
||||
val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f)
|
||||
|
||||
// Message delivery timeout constant (80 seconds, matching archive)
|
||||
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
|
||||
|
||||
/** Check if message can still be delivered (timeout not exceeded) */
|
||||
fun isMessageDeliveredByTime(timestamp: Long, attachmentsCount: Int = 0): Boolean {
|
||||
val maxTime = if (attachmentsCount > 0) {
|
||||
MESSAGE_MAX_TIME_TO_DELIVERED_MS * attachmentsCount
|
||||
} else {
|
||||
MESSAGE_MAX_TIME_TO_DELIVERED_MS
|
||||
}
|
||||
return System.currentTimeMillis() - timestamp < maxTime
|
||||
}
|
||||
|
||||
/** Get date text (today, yesterday or full date) */
|
||||
fun getDateText(timestamp: Long): String {
|
||||
val messageDate = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
val today = Calendar.getInstance()
|
||||
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
|
||||
|
||||
return when {
|
||||
messageDate.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||
messageDate.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) -> "today"
|
||||
messageDate.get(Calendar.YEAR) == yesterday.get(Calendar.YEAR) &&
|
||||
messageDate.get(Calendar.DAY_OF_YEAR) == yesterday.get(Calendar.DAY_OF_YEAR) -> "yesterday"
|
||||
else -> SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH).format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Get user initials from name */
|
||||
fun getInitials(name: String): String {
|
||||
return name.trim()
|
||||
.split(Regex("\\s+"))
|
||||
.filter { it.isNotEmpty() }
|
||||
.let { words ->
|
||||
when {
|
||||
words.isEmpty() -> "??"
|
||||
words.size == 1 -> words[0].take(2).uppercase()
|
||||
else -> "${words[0].first()}${words[1].first()}".uppercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get consistent avatar colors based on name/key */
|
||||
fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors {
|
||||
val colors = listOf(
|
||||
Pair(Color(0xFFFF6B6B), Color.White),
|
||||
Pair(Color(0xFF4ECDC4), Color.White),
|
||||
Pair(Color(0xFF45B7D1), Color.White),
|
||||
Pair(Color(0xFFF7B731), Color.White),
|
||||
Pair(Color(0xFF5F27CD), Color.White),
|
||||
Pair(Color(0xFF00D2D3), Color.White),
|
||||
Pair(Color(0xFFFF9FF3), Color.White),
|
||||
Pair(Color(0xFF54A0FF), Color.White)
|
||||
)
|
||||
val index = name.hashCode().let { if (it < 0) -it else it } % colors.size
|
||||
val (bg, text) = colors[index]
|
||||
return AvatarColors(backgroundColor = bg, textColor = text)
|
||||
}
|
||||
|
||||
/** Telegram Send Icon (horizontal plane) - custom SVG icon */
|
||||
val TelegramSendIcon: ImageVector
|
||||
get() = ImageVector.Builder(
|
||||
name = "TelegramSendHorizontal",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).apply {
|
||||
path(
|
||||
fill = null,
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(3.714f, 3.048f)
|
||||
arcToRelative(0.498f, 0.498f, 0f, false, false, -0.683f, 0.627f)
|
||||
lineToRelative(2.843f, 7.627f)
|
||||
arcToRelative(2f, 2f, 0f, false, true, 0f, 1.396f)
|
||||
lineToRelative(-2.842f, 7.627f)
|
||||
arcToRelative(0.498f, 0.498f, 0f, false, false, 0.682f, 0.627f)
|
||||
lineToRelative(18f, -8.5f)
|
||||
arcToRelative(0.5f, 0.5f, 0f, false, false, 0f, -0.904f)
|
||||
close()
|
||||
}
|
||||
path(
|
||||
fill = null,
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(6f, 12f)
|
||||
horizontalLineToRelative(16f)
|
||||
}
|
||||
}.build()
|
||||
Reference in New Issue
Block a user