This commit is contained in:
Халимов Рустам
2026-04-14 10:48:07 +03:00
parent dc051fa9ae
commit d8b0d86534
19 changed files with 504 additions and 184 deletions
@@ -20,6 +20,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chats.presentation.components.EmojiPicker
import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar
import core.utils.VoiceRecorder
import core.utils.copyUriToFile
import java.io.File
@@ -67,14 +68,22 @@ fun ChatDetailScreen(
topBar = {
TopAppBar(
title = {
Column {
Text(chatName, style = MaterialTheme.typography.titleMedium)
if (state.isTyping) {
Text(
stringResource(R.string.typing),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically) {
AppAvatar(
url = state.chatAvatar,
name = state.chatName ?: chatName,
size = 36.dp,
modifier = Modifier.padding(end = 8.dp)
)
Column {
Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium)
if (state.isTyping) {
Text(
stringResource(R.string.typing),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
},
@@ -116,9 +125,12 @@ fun ChatDetailScreen(
items(state.messages) { message ->
MessageBubble(
message = message,
isCurrentUser = message.senderId == viewModel.getCurrentUserId()
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
senderAvatar = if (message.senderId == viewModel.getCurrentUserId()) null else message.senderAvatar,
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) }
)
}
}
if (state.isLoading) {
@@ -19,6 +19,8 @@ import javax.inject.Inject
data class ChatDetailState(
val messages: List<Message> = emptyList(),
val chatName: String? = null,
val chatAvatar: String? = null,
val isLoading: Boolean = false,
val isTyping: Boolean = false,
val typingUser: String? = null,
@@ -56,10 +58,25 @@ class ChatDetailViewModel @Inject constructor(
fun setChatId(chatId: String) {
currentChatId = chatId
loadChatInfo(chatId)
loadMessages(chatId)
observeSignalREvents()
}
private fun loadChatInfo(chatId: String) {
viewModelScope.launch {
try {
val chats = repository.getChats()
val chat = chats.find { it.id == chatId }
chat?.let { c ->
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
}
} catch (e: Exception) {
// Ignore info load error
}
}
}
fun loadMessages(chatId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
@@ -88,8 +105,9 @@ class ChatDetailViewModel @Inject constructor(
when (event) {
is ChatEvent.NewMessage -> {
// Avoid adding duplicates if already loaded
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s ->
val domainMsg = event.message.toDomain()
val domainMsg = event.message.toDomain(baseUrl)
if (s.messages.none { it.id == domainMsg.id }) {
s.copy(messages = s.messages + domainMsg)
} else s
@@ -39,10 +39,18 @@ class ChatListViewModel @Inject constructor(
true
}
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
signalrClient.connect(baseUrl, token)
}
loadChats()
observeSignalREvents()
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() {
@@ -66,7 +74,8 @@ class ChatListViewModel @Inject constructor(
}
is ChatEvent.NewChat -> {
val currentUserId = getCurrentUserId()
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId)) + it.chats) }
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
}
else -> Unit
}
@@ -76,11 +85,12 @@ class ChatListViewModel @Inject constructor(
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.message.chatId) {
chat.copy(
lastMessage = event.message.toDomain(),
lastMessage = event.message.toDomain(baseUrl),
unreadCount = chat.unreadCount + 1
)
} else chat
@@ -22,19 +22,29 @@ import chats.domain.model.MediaType
import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
import core.presentation.components.AppAvatar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import chats.presentation.components.LinkPreview
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Icon
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.shape.CircleShape
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: Message,
isCurrentUser: Boolean,
senderAvatar: String? = null,
onReactionClick: (String) -> Unit = {}
) {
val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) }
val backgroundColor = if (isCurrentUser) {
@@ -47,22 +57,33 @@ fun MessageBubble(
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) {
RoundedCornerShape(12.dp, 12.dp, 4.dp, 12.dp)
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else {
RoundedCornerShape(12.dp, 12.dp, 12.dp, 4.dp)
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
(message.content == null || message.content.isEmpty())
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = alignment
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (!isCurrentUser) {
AppAvatar(
url = senderAvatar,
name = message.senderName,
size = 32.dp,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)
)
}
Column(
modifier = Modifier
.widthIn(max = 300.dp)
.clip(shape)
.background(backgroundColor)
.combinedClickable(
@@ -70,7 +91,6 @@ fun MessageBubble(
onLongClick = { showReactionPicker = true }
)
.padding(8.dp)
.widthIn(max = 300.dp)
) {
if (showReactionPicker) {
Popup(
@@ -84,37 +104,35 @@ fun MessageBubble(
})
}
}
// Sender Name (only for others)
if (!isCurrentUser) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
)
}
// Reply Info
message.replyTo?.let { reply ->
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp)
.height(IntrinsicSize.Min)
) {
Column {
Box(
modifier = Modifier
.fillMaxHeight()
.width(2.dp)
.background(if (isCurrentUser) Color.White else Color(0xFF3390EC))
)
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = reply.senderName,
style = MaterialTheme.typography.labelSmall,
color = contentColor,
maxLines = 1
style = MaterialTheme.typography.labelMedium,
color = if (isCurrentUser) Color.White else Color(0xFF3390EC),
maxLines = 1,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
Text(
text = reply.content ?: "[Media]",
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
color = contentColor.copy(alpha = 0.8f),
maxLines = 1
)
}
@@ -122,57 +140,65 @@ fun MessageBubble(
}
// Media Content
if (message.mediaUrl != null) {
when (message.mediaType) {
MediaType.IMAGE -> {
AsyncImage(
model = message.mediaUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(4.dp))
if (message.media.isNotEmpty()) {
val mediaCount = message.media.size
if (mediaCount == 1) {
val mediaUrl = message.media[0]
when (message.mediaType) {
MediaType.IMAGE -> {
AsyncImage(
model = mediaUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(mediaUrl))
context.startActivity(intent)
},
contentScale = ContentScale.FillWidth
)
}
MediaType.VIDEO -> {
AppVideoPlayer(
url = mediaUrl,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp)),
useController = true,
autoPlay = false
)
}
MediaType.AUDIO -> {
AppAudioPlayer(
url = mediaUrl,
isVoiceMessage = isVoiceMessage,
contentColor = contentColor
)
}
MediaType.FILE -> {
FileItem(mediaUrl = mediaUrl, isCurrentUser = isCurrentUser, contentColor = contentColor)
}
else -> {}
}
MediaType.VIDEO -> {
AppVideoPlayer(
url = message.mediaUrl,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp)),
useController = true,
autoPlay = false
)
Spacer(modifier = Modifier.height(4.dp))
}
MediaType.AUDIO -> {
AppAudioPlayer(
url = message.mediaUrl,
isVoiceMessage = isVoiceMessage,
contentColor = contentColor
)
Spacer(modifier = Modifier.height(4.dp))
}
else -> {}
} else if (mediaCount > 1) {
// Photo Grid for multiple images
PhotoGrid(
urls = message.media,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.clip(RoundedCornerShape(12.dp))
)
}
if (mediaCount > 0) {
Spacer(modifier = Modifier.height(4.dp))
}
}
// Text Content (Story reply or simple text)
if (message.mediaType == MediaType.STORY_REPLY) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp)
) {
Text("Story Reply: ${message.content}", color = contentColor, style = MaterialTheme.typography.bodyMedium)
}
} else if (!message.content.isNullOrBlank() && !isVoiceMessage) {
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
@@ -185,20 +211,157 @@ fun MessageBubble(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick,
modifier = Modifier.padding(end = 4.dp)
)
}
Text(
text = message.createdAt.takeLast(5),
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f)
)
}
// Reactions
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick
color = contentColor.copy(alpha = 0.6f),
fontSize = 10.sp
)
if (isCurrentUser) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
}
}
}
}
}
@Composable
fun MessageReactions(
reactions: Map<String, Int>,
onReactionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { (emoji, count) ->
Box(
modifier = Modifier
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { onReactionClick(emoji) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = emoji, fontSize = 12.sp)
if (count > 1) {
Spacer(modifier = Modifier.width(2.dp))
Text(
text = count.toString(),
fontSize = 10.sp,
color = Color.White
)
}
}
}
}
}
}
@Composable
fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Description,
contentDescription = null,
tint = contentColor
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
val fileName = remember(mediaUrl) {
val decoded = Uri.decode(mediaUrl.substringAfterLast("/"))
if (decoded.length > 30) {
decoded.take(15) + "..." + decoded.takeLast(10)
} else {
decoded
}
}
Text(
text = fileName,
style = MaterialTheme.typography.bodySmall,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = if (mediaUrl.endsWith(".mp3", ignoreCase = true) || mediaUrl.contains("audio")) "Audio" else "File",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f)
)
}
Icon(
Icons.Default.Download,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(20.dp)
)
}
}
@Composable
fun PhotoGrid(urls: List<String>, modifier: Modifier = Modifier) {
val items = urls.take(4)
Column(modifier = modifier) {
val rows = (items.size + 1) / 2
for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f)) {
val firstIndex = i * 2
AsyncImage(
model = items[firstIndex],
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp),
contentScale = ContentScale.Crop
)
if (firstIndex + 1 < items.size) {
AsyncImage(
model = items[firstIndex + 1],
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp),
contentScale = ContentScale.Crop
)
} else if (rows > 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}