feat: Add ChatDetailScreen and implement chat navigation with animated transitions

This commit is contained in:
k1ngsterr1
2026-01-10 20:16:27 +05:00
parent 3d8c9570b4
commit 1ed5d90055
2 changed files with 652 additions and 39 deletions

View File

@@ -26,6 +26,8 @@ import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
@@ -214,44 +216,84 @@ fun MainScreen(
val accountPublicKey = account?.publicKey ?: "04c266b98ae5" val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
val privateKeyHash = account?.privateKeyHash ?: "" val privateKeyHash = account?.privateKeyHash ?: ""
ChatsListScreen( // Навигация между экранами
isDarkTheme = isDarkTheme, var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
accountName = accountName,
accountPhone = accountPhone, // Анимированный переход между чатами
accountPublicKey = accountPublicKey, AnimatedContent(
privateKeyHash = privateKeyHash, targetState = selectedUser,
onToggleTheme = onToggleTheme, transitionSpec = {
onProfileClick = { if (targetState != null) {
// TODO: Navigate to profile // Открытие чата - слайд слева
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(300)
) togetherWith slideOutHorizontally(
targetOffsetX = { -it / 3 },
animationSpec = tween(300)
)
} else {
// Закрытие чата - слайд справа
slideInHorizontally(
initialOffsetX = { -it / 3 },
animationSpec = tween(300)
) togetherWith slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(300)
)
}
}, },
onNewGroupClick = { label = "chatNavigation"
// TODO: Navigate to new group ) { user ->
}, if (user != null) {
onContactsClick = { // Экран чата
// TODO: Navigate to contacts ChatDetailScreen(
}, user = user,
onCallsClick = { currentUserPublicKey = accountPublicKey,
// TODO: Navigate to calls isDarkTheme = isDarkTheme,
}, onBack = { selectedUser = null }
onSavedMessagesClick = { )
// TODO: Navigate to saved messages } else {
}, // Список чатов
onSettingsClick = { ChatsListScreen(
// TODO: Navigate to settings isDarkTheme = isDarkTheme,
}, accountName = accountName,
onInviteFriendsClick = { accountPhone = accountPhone,
// TODO: Share invite link accountPublicKey = accountPublicKey,
}, privateKeyHash = privateKeyHash,
onSearchClick = { onToggleTheme = onToggleTheme,
// TODO: Show search onProfileClick = {
}, // TODO: Navigate to profile
onNewChat = { },
// TODO: Show new chat screen onNewGroupClick = {
}, // TODO: Navigate to new group
onUserSelect = { user -> },
// TODO: Navigate to chat with selected user onContactsClick = {
android.util.Log.d("MainScreen", "User selected: ${user.publicKey}") // TODO: Navigate to contacts
}, },
onLogout = onLogout onCallsClick = {
) // TODO: Navigate to calls
},
onSavedMessagesClick = {
// TODO: Navigate to saved messages
},
onSettingsClick = {
// TODO: Navigate to settings
},
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = {
// TODO: Show search
},
onNewChat = {
// TODO: Show new chat screen
},
onUserSelect = { user ->
selectedUser = user
},
onLogout = onLogout
)
}
}
} }

View File

@@ -0,0 +1,571 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.graphics.Color
import androidx.compose.ui.graphics.SolidColor
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 androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.imePadding
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.VerifiedBadge
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
/**
* Модель сообщения
*/
data class ChatMessage(
val id: String,
val text: String,
val isOutgoing: Boolean,
val timestamp: Date,
val status: MessageStatus = MessageStatus.SENT
)
enum class MessageStatus {
SENDING, SENT, DELIVERED, READ
}
/**
* Экран детального чата с пользователем
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatDetailScreen(
user: SearchUser,
currentUserPublicKey: String,
isDarkTheme: Boolean,
onBack: () -> Unit,
onUserProfileClick: () -> Unit = {}
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
// Определяем это Saved Messages или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
// Состояние сообщений
var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Аватар
val avatarColors = getAvatarColor(
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
isDarkTheme
)
// Анимация появления
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
// Логируем открытие чата
LaunchedEffect(user.publicKey) {
ProtocolManager.addLog("💬 Chat opened with: ${user.title.ifEmpty { user.publicKey.take(10) }}")
ProtocolManager.addLog(" PublicKey: ${user.publicKey.take(20)}...")
}
Scaffold(
topBar = {
// Кастомный TopAppBar для чата
Surface(
color = backgroundColor,
shadowElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(56.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка назад
IconButton(onClick = onBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = textColor
)
}
// Аватар
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isSavedMessages) PrimaryBlue else avatarColors.backgroundColor)
.clickable { onUserProfileClick() },
contentAlignment = Alignment.Center
) {
if (isSavedMessages) {
Icon(
Icons.Default.Bookmark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
} else {
Text(
text = if (user.title.isNotEmpty()) getInitials(user.title) else user.publicKey.take(2).uppercase(),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// Информация о пользователе
Column(
modifier = Modifier
.weight(1f)
.clickable { onUserProfileClick() }
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = chatTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!isSavedMessages && user.verified > 0) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(verified = user.verified, size = 16)
}
}
Text(
text = chatSubtitle,
fontSize = 13.sp,
color = if (!isSavedMessages && user.online == 1) PrimaryBlue else secondaryTextColor,
maxLines = 1
)
}
// Кнопки действий
if (!isSavedMessages) {
IconButton(onClick = { /* TODO: Voice call */ }) {
Icon(
Icons.Default.Call,
contentDescription = "Call",
tint = textColor
)
}
}
IconButton(onClick = { /* TODO: More options */ }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More",
tint = textColor
)
}
}
}
},
containerColor = backgroundColor
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding() // Весь контент поднимается с клавиатурой
) {
// Список сообщений
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (messages.isEmpty()) {
// Пустое состояние
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
if (isSavedMessages) Icons.Default.Bookmark else Icons.Default.Chat,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.5f),
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (isSavedMessages)
"Save messages here for quick access"
else
"No messages yet",
fontSize = 16.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (isSavedMessages)
"Forward messages here or send notes to yourself"
else
"Send a message to start the conversation",
fontSize = 14.sp,
color = secondaryTextColor.copy(alpha = 0.7f)
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
reverseLayout = true
) {
items(messages.reversed(), key = { it.id }) { message ->
MessageBubble(
message = message,
isDarkTheme = isDarkTheme
)
}
}
}
}
// Поле ввода сообщения
MessageInputBar(
value = inputText,
onValueChange = { inputText = it },
onSend = {
if (inputText.isNotBlank()) {
val newMessage = ChatMessage(
id = UUID.randomUUID().toString(),
text = inputText.trim(),
isOutgoing = true,
timestamp = Date(),
status = MessageStatus.SENDING
)
messages = messages + newMessage
// Логируем отправку
ProtocolManager.addLog("📤 Message sent: \"${inputText.take(30)}...\"")
inputText = ""
// Прокрутка вниз
scope.launch {
listState.animateScrollToItem(0)
}
// TODO: Отправить через протокол
}
},
isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor,
textColor = textColor,
placeholderColor = secondaryTextColor
)
}
}
}
/**
* Пузырек сообщения
*/
@Composable
private fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean
) {
val bubbleColor = if (message.isOutgoing) {
PrimaryBlue
} else {
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
}
val textColor = if (message.isOutgoing) Color.White else {
if (isDarkTheme) Color.White else Color.Black
}
val timeColor = if (message.isOutgoing) Color.White.copy(alpha = 0.7f) else {
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
}
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) {
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (message.isOutgoing) 16.dp else 4.dp,
bottomEnd = if (message.isOutgoing) 4.dp else 16.dp
)
)
.background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
Text(
text = message.text,
color = textColor,
fontSize = 15.sp
)
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
when (message.status) {
MessageStatus.SENDING -> Icons.Default.Schedule
MessageStatus.SENT -> Icons.Default.Done
MessageStatus.DELIVERED -> Icons.Default.DoneAll
MessageStatus.READ -> Icons.Default.DoneAll
},
contentDescription = null,
tint = if (message.status == MessageStatus.READ)
Color(0xFF4CAF50)
else
timeColor,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
/**
* Панель ввода сообщения со стеклянным эффектом (Glass Morphism)
* Все иконки внутри одного стеклянного инпута
*/
@Composable
private fun MessageInputBar(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
placeholderColor: Color
) {
// Цвета для glass morphism эффекта
val glassBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.85f)
else
Color(0xFFFFFFFF).copy(alpha = 0.9f)
val inputGlass = if (isDarkTheme)
Color(0xFF2C2C2E).copy(alpha = 0.7f)
else
Color(0xFFF2F2F7).copy(alpha = 0.85f)
val inputBorder = if (isDarkTheme)
Color(0xFFFFFFFF).copy(alpha = 0.15f)
else
Color(0xFF000000).copy(alpha = 0.08f)
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
// Основной контейнер
Box(
modifier = Modifier
.fillMaxWidth()
.background(glassBackground)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.padding(bottom = 4.dp)
.navigationBarsPadding(),
verticalAlignment = Alignment.Bottom
) {
// Единый стеклянный контейнер для всего инпута
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 44.dp, max = 120.dp)
.clip(RoundedCornerShape(22.dp))
.background(inputGlass)
.border(
width = 1.dp,
color = inputBorder,
shape = RoundedCornerShape(22.dp)
)
// Блик сверху для стеклянного эффекта
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = if (isDarkTheme) 0.06f else 0.4f),
Color.Transparent,
Color.Black.copy(alpha = if (isDarkTheme) 0.03f else 0.02f)
),
startY = 0f,
endY = 80f
),
shape = RoundedCornerShape(22.dp)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка смайликов (слева внутри инпута)
IconButton(
onClick = { /* TODO: Emoji picker */ },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = "Emoji",
tint = iconColor,
modifier = Modifier.size(22.dp)
)
}
// Текстовое поле
Box(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = androidx.compose.ui.text.TextStyle(
color = textColor,
fontSize = 16.sp
),
cursorBrush = SolidColor(PrimaryBlue),
modifier = Modifier.fillMaxWidth(),
maxLines = 5,
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
Text(
text = "Message",
color = placeholderColor.copy(alpha = 0.6f),
fontSize = 16.sp
)
}
innerTextField()
}
}
)
}
// Кнопка прикрепления (справа внутри инпута)
IconButton(
onClick = { /* TODO: Attachment picker */ },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.AttachFile,
contentDescription = "Attach",
tint = iconColor,
modifier = Modifier.size(22.dp)
)
}
// Кнопка камеры (справа внутри инпута)
IconButton(
onClick = { /* TODO: Camera */ },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = "Camera",
tint = iconColor,
modifier = Modifier.size(22.dp)
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
// Кнопка отправки (появляется только когда есть текст)
AnimatedVisibility(
visible = value.isNotBlank(),
enter = scaleIn(animationSpec = tween(150)) + fadeIn(animationSpec = tween(150)),
exit = scaleOut(animationSpec = tween(150)) + fadeOut(animationSpec = tween(150))
) {
IconButton(
onClick = onSend,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
PrimaryBlue,
PrimaryBlue.copy(alpha = 0.85f)
)
)
)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}