diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index e93d112..1bda167 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -26,6 +26,8 @@ import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow 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.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme @@ -214,44 +216,84 @@ fun MainScreen( val accountPublicKey = account?.publicKey ?: "04c266b98ae5" val privateKeyHash = account?.privateKeyHash ?: "" - ChatsListScreen( - isDarkTheme = isDarkTheme, - accountName = accountName, - accountPhone = accountPhone, - accountPublicKey = accountPublicKey, - privateKeyHash = privateKeyHash, - onToggleTheme = onToggleTheme, - onProfileClick = { - // TODO: Navigate to profile + // Навигация между экранами + var selectedUser by remember { mutableStateOf(null) } + + // Анимированный переход между чатами + AnimatedContent( + targetState = selectedUser, + transitionSpec = { + if (targetState != null) { + // Открытие чата - слайд слева + 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 = { - // TODO: Navigate to new group - }, - onContactsClick = { - // TODO: Navigate to contacts - }, - 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 -> - // TODO: Navigate to chat with selected user - android.util.Log.d("MainScreen", "User selected: ${user.publicKey}") - }, - onLogout = onLogout - ) + label = "chatNavigation" + ) { user -> + if (user != null) { + // Экран чата + ChatDetailScreen( + user = user, + currentUserPublicKey = accountPublicKey, + isDarkTheme = isDarkTheme, + onBack = { selectedUser = null } + ) + } else { + // Список чатов + ChatsListScreen( + isDarkTheme = isDarkTheme, + accountName = accountName, + accountPhone = accountPhone, + accountPublicKey = accountPublicKey, + privateKeyHash = privateKeyHash, + onToggleTheme = onToggleTheme, + onProfileClick = { + // TODO: Navigate to profile + }, + onNewGroupClick = { + // TODO: Navigate to new group + }, + onContactsClick = { + // TODO: Navigate to contacts + }, + 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 + ) + } + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt new file mode 100644 index 0000000..b0a9547 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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>(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) + ) + } + } + } + } +}