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:
k1ngsterr1
2026-01-22 04:56:17 +05:00
parent ae8e1b0376
commit 60d4d1e6bc
7 changed files with 1455 additions and 2251 deletions

View File

@@ -687,7 +687,6 @@ fun MainScreen(
user = selectedUser!!,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
onBack = { selectedUser = null },
onUserProfileClick = { user ->
// Открываем профиль другого пользователя

View File

@@ -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

View File

@@ -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)
)
}

View File

@@ -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()
)
}
}
}
}

View File

@@ -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
}
)

View File

@@ -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()