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!!,
|
user = selectedUser!!,
|
||||||
currentUserPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
currentUserPrivateKey = accountPrivateKey,
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
onBack = { selectedUser = null },
|
onBack = { selectedUser = null },
|
||||||
onUserProfileClick = { user ->
|
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.MessageEntity
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.*
|
import com.rosetta.messenger.network.*
|
||||||
|
import com.rosetta.messenger.ui.chats.models.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import org.json.JSONArray
|
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