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
Binary file not shown.
Binary file not shown.
@@ -51,7 +51,7 @@ interface ChatApi {
suspend fun markGifShared(@Path("id") id: String, @Body query: String) suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@POST("messages/{messageId}/reactions") @POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Body emoji: String) suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/{chatId}/typing") @POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String) suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@@ -18,7 +18,14 @@ data class MessageDto(
@SerializedName("sequenceId") val sequenceId: Int, @SerializedName("sequenceId") val sequenceId: Int,
@SerializedName("createdAt") val createdAt: String, @SerializedName("createdAt") val createdAt: String,
@SerializedName("sender") val sender: UserBasicDto, @SerializedName("sender") val sender: UserBasicDto,
@SerializedName("media") val media: List<MediaItemDto> = emptyList() @SerializedName("media") val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions") val reactions: List<ReactionDto>? = emptyList()
)
data class ReactionDto(
@SerializedName("emoji") val emoji: String,
@SerializedName("count") val count: Int,
@SerializedName("isSetByMe") val isSetByMe: Boolean
) )
data class MediaItemDto( data class MediaItemDto(
@@ -10,6 +10,9 @@ import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -18,6 +21,7 @@ class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64) private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow() val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO)
fun connect(baseUrl: String, accessToken: String) { fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
@@ -30,9 +34,17 @@ class ChatHubClient @Inject constructor() {
hubConnection?.onClosed { exception -> hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed", exception) Log.e("ChatHubClient", "Connection closed", exception)
// Optional: Reconnect logic
} }
hubConnection?.start()?.blockingAwait() scope.launch {
try {
hubConnection?.start()?.blockingAwait()
Log.d("ChatHubClient", "SignalR Connected")
} catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e)
}
}
} }
private fun setupHandlers() { private fun setupHandlers() {
@@ -8,6 +8,7 @@ import chats.domain.model.Chat
import chats.domain.model.Message import chats.domain.model.Message
import chats.domain.model.MediaType import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -16,21 +17,25 @@ import javax.inject.Inject
class ChatRepositoryImpl @Inject constructor( class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi, private val api: ChatApi,
private val tokenManager: TokenManager private val tokenManager: TokenManager,
private val serverConfig: ServerConfig
) : ChatRepository { ) : ChatRepository {
override suspend fun getChats(): List<Chat> { override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: "" val currentUserId = tokenManager.getUserId() ?: ""
return api.getChats().map { it.toDomain(currentUserId) } val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
} }
override suspend fun getMessages(chatId: String): List<Message> { override suspend fun getMessages(chatId: String): List<Message> {
return api.getMessages(chatId).map { it.toDomain() } val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getMessages(chatId).map { it.toDomain(baseUrl) }
} }
override suspend fun sendMessage(chatId: String, content: String): Message { override suspend fun sendMessage(chatId: String, content: String): Message {
val request = SendMessageRequest(content = content, type = "text") val request = SendMessageRequest(content = content, type = "text")
return api.sendMessage(chatId, request).toDomain() val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.sendMessage(chatId, request).toDomain(baseUrl)
} }
override suspend fun addReaction(messageId: String, emoji: String) { override suspend fun addReaction(messageId: String, emoji: String) {
@@ -56,15 +61,15 @@ class ChatRepositoryImpl @Inject constructor(
// Mappers // Mappers
fun ChatDto.toDomain(currentUserId: String): Chat { fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
// Если это личный чат и имя пустое, ищем имя собеседника в списке участников // Если это личный чат и имя пустое, ищем имя собеседника в списке участников
val chatName = name ?: if (type == "personal") { val chatName = name ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat" members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
} else "Group Chat" } else "Group Chat"
val chatAvatar = avatar ?: if (type == "personal") { val chatAvatar = (avatar ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
} else null } else null)?.ensureAbsoluteUrl(baseUrl)
return Chat( return Chat(
id = id, id = id,
@@ -72,25 +77,48 @@ fun ChatDto.toDomain(currentUserId: String): Chat {
name = chatName, name = chatName,
avatar = chatAvatar, avatar = chatAvatar,
unreadCount = unreadCount, unreadCount = unreadCount,
lastMessage = messages.firstOrNull()?.toDomain() lastMessage = messages.firstOrNull()?.toDomain(baseUrl)
) )
} }
fun MessageDto.toDomain(): Message = Message( fun MessageDto.toDomain(baseUrl: String): Message {
id = id, val domainMediaType = when (type) {
chatId = chatId, "image", "photo" -> MediaType.IMAGE
senderId = senderId,
senderName = sender.displayName,
content = content,
sequenceId = sequenceId,
createdAt = createdAt,
media = media.map { it.url },
mediaUrl = media.firstOrNull()?.url,
mediaType = when (media.firstOrNull()?.type) {
"image" -> MediaType.IMAGE
"video" -> MediaType.VIDEO "video" -> MediaType.VIDEO
"audio" -> MediaType.AUDIO "audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE "file" -> MediaType.FILE
else -> MediaType.TEXT else -> when (media.firstOrNull()?.type) {
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> MediaType.TEXT
}
} }
)
return Message(
id = id,
chatId = chatId,
senderId = senderId,
senderName = sender.displayName,
senderAvatar = sender.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content,
sequenceId = sequenceId,
createdAt = createdAt,
media = media.map { it.url.ensureAbsoluteUrl(baseUrl) },
mediaUrl = media.firstOrNull()?.url?.ensureAbsoluteUrl(baseUrl),
mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap()
)
}
fun String.ensureAbsoluteUrl(baseUrl: String): String {
return if (this.startsWith("http")) {
this
} else {
val base = baseUrl.removeSuffix("/")
val path = if (this.startsWith("/")) this else "/$this"
"$base$path"
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl import chats.data.repository.ChatRepositoryImpl
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -24,8 +25,8 @@ object ChatModule {
@Provides @Provides
@Singleton @Singleton
fun provideChatRepository(api: ChatApi, tokenManager: TokenManager): ChatRepository { fun provideChatRepository(api: ChatApi, tokenManager: TokenManager, serverConfig: ServerConfig): ChatRepository {
return ChatRepositoryImpl(api, tokenManager) return ChatRepositoryImpl(api, tokenManager, serverConfig)
} }
@Provides @Provides
@@ -5,6 +5,7 @@ data class Message(
val chatId: String, val chatId: String,
val senderId: String, val senderId: String,
val senderName: String, val senderName: String,
val senderAvatar: String? = null,
val content: String?, val content: String?,
val sequenceId: Int, val sequenceId: Int,
val createdAt: String, val createdAt: String,
@@ -20,6 +20,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chats.presentation.components.EmojiPicker import chats.presentation.components.EmojiPicker
import chats.presentation.components.MessageBubble import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar
import core.utils.VoiceRecorder import core.utils.VoiceRecorder
import core.utils.copyUriToFile import core.utils.copyUriToFile
import java.io.File import java.io.File
@@ -67,14 +68,22 @@ fun ChatDetailScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Column { Row(verticalAlignment = Alignment.CenterVertically) {
Text(chatName, style = MaterialTheme.typography.titleMedium) AppAvatar(
if (state.isTyping) { url = state.chatAvatar,
Text( name = state.chatName ?: chatName,
stringResource(R.string.typing), size = 36.dp,
style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(end = 8.dp)
color = MaterialTheme.colorScheme.primary )
) 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 -> items(state.messages) { message ->
MessageBubble( MessageBubble(
message = message, 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) { if (state.isLoading) {
@@ -19,6 +19,8 @@ import javax.inject.Inject
data class ChatDetailState( data class ChatDetailState(
val messages: List<Message> = emptyList(), val messages: List<Message> = emptyList(),
val chatName: String? = null,
val chatAvatar: String? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isTyping: Boolean = false, val isTyping: Boolean = false,
val typingUser: String? = null, val typingUser: String? = null,
@@ -56,10 +58,25 @@ class ChatDetailViewModel @Inject constructor(
fun setChatId(chatId: String) { fun setChatId(chatId: String) {
currentChatId = chatId currentChatId = chatId
loadChatInfo(chatId)
loadMessages(chatId) loadMessages(chatId)
observeSignalREvents() 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) { fun loadMessages(chatId: String) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true) } _state.update { it.copy(isLoading = true) }
@@ -88,8 +105,9 @@ class ChatDetailViewModel @Inject constructor(
when (event) { when (event) {
is ChatEvent.NewMessage -> { is ChatEvent.NewMessage -> {
// Avoid adding duplicates if already loaded // Avoid adding duplicates if already loaded
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s -> _state.update { s ->
val domainMsg = event.message.toDomain() val domainMsg = event.message.toDomain(baseUrl)
if (s.messages.none { it.id == domainMsg.id }) { if (s.messages.none { it.id == domainMsg.id }) {
s.copy(messages = s.messages + domainMsg) s.copy(messages = s.messages + domainMsg)
} else s } else s
@@ -39,10 +39,18 @@ class ChatListViewModel @Inject constructor(
true true
} }
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) } _state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
signalrClient.connect(baseUrl, token)
}
loadChats() loadChats()
observeSignalREvents() observeSignalREvents()
} }
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: "" private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() { fun loadChats() {
@@ -66,7 +74,8 @@ class ChatListViewModel @Inject constructor(
} }
is ChatEvent.NewChat -> { is ChatEvent.NewChat -> {
val currentUserId = getCurrentUserId() 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 else -> Unit
} }
@@ -76,11 +85,12 @@ class ChatListViewModel @Inject constructor(
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) { private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { currentState -> _state.update { currentState ->
val updatedChats = currentState.chats.map { chat -> val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.message.chatId) { if (chat.id == event.message.chatId) {
chat.copy( chat.copy(
lastMessage = event.message.toDomain(), lastMessage = event.message.toDomain(baseUrl),
unreadCount = chat.unreadCount + 1 unreadCount = chat.unreadCount + 1
) )
} else chat } else chat
@@ -22,19 +22,29 @@ import chats.domain.model.MediaType
import coil.compose.AsyncImage import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer 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 chats.presentation.components.LinkPreview
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.ui.platform.LocalContext 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) @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun MessageBubble( fun MessageBubble(
message: Message, message: Message,
isCurrentUser: Boolean, isCurrentUser: Boolean,
senderAvatar: String? = null,
onReactionClick: (String) -> Unit = {} onReactionClick: (String) -> Unit = {}
) { ) {
val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) } var showReactionPicker by remember { mutableStateOf(false) }
val backgroundColor = if (isCurrentUser) { val backgroundColor = if (isCurrentUser) {
@@ -47,22 +57,33 @@ fun MessageBubble(
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) { val shape = if (isCurrentUser) {
RoundedCornerShape(12.dp, 12.dp, 4.dp, 12.dp) RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else { } 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 && val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
(message.content == null || message.content.isEmpty()) (message.content == null || message.content.isEmpty())
Box( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = alignment 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( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 300.dp)
.clip(shape) .clip(shape)
.background(backgroundColor) .background(backgroundColor)
.combinedClickable( .combinedClickable(
@@ -70,7 +91,6 @@ fun MessageBubble(
onLongClick = { showReactionPicker = true } onLongClick = { showReactionPicker = true }
) )
.padding(8.dp) .padding(8.dp)
.widthIn(max = 300.dp)
) { ) {
if (showReactionPicker) { if (showReactionPicker) {
Popup( 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 // Reply Info
message.replyTo?.let { reply -> message.replyTo?.let { reply ->
Box( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 4.dp) .padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f)) .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(
text = reply.senderName, text = reply.senderName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
color = contentColor, color = if (isCurrentUser) Color.White else Color(0xFF3390EC),
maxLines = 1 maxLines = 1,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
) )
Text( Text(
text = reply.content ?: "[Media]", text = reply.content ?: "[Media]",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f), color = contentColor.copy(alpha = 0.8f),
maxLines = 1 maxLines = 1
) )
} }
@@ -122,57 +140,65 @@ fun MessageBubble(
} }
// Media Content // Media Content
if (message.mediaUrl != null) { if (message.media.isNotEmpty()) {
when (message.mediaType) { val mediaCount = message.media.size
MediaType.IMAGE -> { if (mediaCount == 1) {
AsyncImage( val mediaUrl = message.media[0]
model = message.mediaUrl, when (message.mediaType) {
contentDescription = null, MediaType.IMAGE -> {
modifier = Modifier AsyncImage(
.fillMaxWidth() model = mediaUrl,
.heightIn(max = 200.dp) contentDescription = null,
.clip(RoundedCornerShape(12.dp)), modifier = Modifier
contentScale = ContentScale.Crop .fillMaxWidth()
) .clip(RoundedCornerShape(12.dp))
Spacer(modifier = Modifier.height(4.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 -> { } else if (mediaCount > 1) {
AppVideoPlayer( // Photo Grid for multiple images
url = message.mediaUrl, PhotoGrid(
modifier = Modifier urls = message.media,
.fillMaxWidth() modifier = Modifier
.height(200.dp) .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)), .height(300.dp)
useController = true, .clip(RoundedCornerShape(12.dp))
autoPlay = false )
) }
Spacer(modifier = Modifier.height(4.dp)) if (mediaCount > 0) {
} Spacer(modifier = Modifier.height(4.dp))
MediaType.AUDIO -> {
AppAudioPlayer(
url = message.mediaUrl,
isVoiceMessage = isVoiceMessage,
contentColor = contentColor
)
Spacer(modifier = Modifier.height(4.dp))
}
else -> {}
} }
} }
// Text Content (Story reply or simple text) // Text Content
if (message.mediaType == MediaType.STORY_REPLY) { if (!message.content.isNullOrBlank() && !isVoiceMessage) {
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( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -185,20 +211,157 @@ fun MessageBubble(
modifier = Modifier.align(Alignment.End), modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick,
modifier = Modifier.padding(end = 4.dp)
)
}
Text( Text(
text = message.createdAt.takeLast(5), text = message.createdAt.takeLast(5),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f) color = contentColor.copy(alpha = 0.6f),
) fontSize = 10.sp
}
// Reactions
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick
) )
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))
}
}
}
}
}
@@ -1,8 +1,10 @@
package core.presentation.components package core.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -21,12 +23,16 @@ import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.ui.text.font.FontWeight
@Composable @Composable
fun AppAudioPlayer( fun AppAudioPlayer(
url: String, url: String,
isVoiceMessage: Boolean = false, isVoiceMessage: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant contentColor: Color = Color.White
) { ) {
val context = LocalContext.current val context = LocalContext.current
val exoPlayer = remember { val exoPlayer = remember {
@@ -42,6 +48,8 @@ fun AppAudioPlayer(
var duration by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
val fileName = remember(url) { url.substringAfterLast("/") }
DisposableEffect(Unit) { DisposableEffect(Unit) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { override fun onIsPlayingChanged(playing: Boolean) {
@@ -60,7 +68,6 @@ fun AppAudioPlayer(
} }
} }
// Обновление прогресса
LaunchedEffect(isPlaying) { LaunchedEffect(isPlaying) {
while (isPlaying) { while (isPlaying) {
currentPosition = exoPlayer.currentPosition currentPosition = exoPlayer.currentPosition
@@ -68,81 +75,129 @@ fun AppAudioPlayer(
} }
} }
Row( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .clip(RoundedCornerShape(12.dp))
verticalAlignment = Alignment.CenterVertically .background(Color.Black.copy(alpha = 0.2f))
.padding(8.dp)
) { ) {
IconButton( if (!isVoiceMessage) {
onClick = {
if (isPlaying) exoPlayer.pause() else exoPlayer.play()
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = contentColor
)
}
Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
Slider(
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
onValueChange = {
val newPos = (it * duration).toLong()
exoPlayer.seekTo(newPos)
currentPosition = newPos
},
modifier = Modifier.height(24.dp),
colors = SliderDefaults.colors(
thumbColor = contentColor,
activeTrackColor = contentColor,
inactiveTrackColor = contentColor.copy(alpha = 0.3f)
)
)
Row( Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween modifier = Modifier.padding(bottom = 4.dp)
) { ) {
Text( Icon(
text = formatDuration(currentPosition), Icons.Default.GraphicEq,
fontSize = 10.sp, contentDescription = null,
color = contentColor.copy(alpha = 0.7f) tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = formatDuration(duration), text = fileName,
fontSize = 10.sp, fontSize = 12.sp,
color = contentColor.copy(alpha = 0.7f) fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
modifier = Modifier.weight(1f)
) )
} }
} }
if (isVoiceMessage) { Row(
TextButton( verticalAlignment = Alignment.CenterVertically
onClick = { ) {
playbackSpeed = when (playbackSpeed) { Box(
1.0f -> 1.5f modifier = Modifier
1.5f -> 2.0f .size(36.dp)
else -> 1.0f .clip(CircleShape)
} .background(Color.White)
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed) .clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
}, contentAlignment = Alignment.Center
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(40.dp)
) { ) {
Text( Icon(
text = "${playbackSpeed}x", imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
fontSize = 12.sp, contentDescription = null,
color = contentColor, tint = Color(0xFF3390EC),
style = MaterialTheme.typography.labelSmall modifier = Modifier.size(24.dp)
) )
} }
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
Slider(
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
onValueChange = {
val newPos = (it * duration).toLong()
exoPlayer.seekTo(newPos)
currentPosition = newPos
},
modifier = Modifier.height(16.dp),
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatDuration(currentPosition),
fontSize = 10.sp,
color = contentColor.copy(alpha = 0.7f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "3.3 MB", // Mock size
fontSize = 10.sp,
color = contentColor.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Download,
contentDescription = null,
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(12.dp)
)
}
}
}
if (isVoiceMessage) {
Surface(
onClick = {
playbackSpeed = when (playbackSpeed) {
1.0f -> 1.5f
1.5f -> 2.0f
else -> 1.0f
}
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
},
color = Color.White.copy(alpha = 0.2f),
shape = CircleShape,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
} }
} }
} }
private fun formatDuration(durationMs: Long): String { private fun formatDuration(durationMs: Long): String {
val totalSeconds = durationMs / 1000 val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60 val minutes = totalSeconds / 60
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -23,14 +24,25 @@ fun AppAvatar(
size: Dp = 48.dp, size: Dp = 48.dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val initials = remember(name) {
val words = name.trim().split("\\s+".toRegex())
if (words.size >= 2) {
(words[0].take(1) + words[1].take(1)).uppercase()
} else if (name.length >= 2) {
name.take(2).uppercase()
} else {
name.take(1).uppercase()
}
}
Box( Box(
modifier = modifier modifier = modifier
.size(size) .size(size)
.clip(SoftSquareShape) // Тот самый "мягкий квадрат" .clip(SoftSquareShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (url != null) { if (!url.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = url, model = url,
contentDescription = name, contentDescription = name,
@@ -38,13 +50,14 @@ fun AppAvatar(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else { } else {
// Заглушка, если нет аватара (первая буква имени)
Text( Text(
text = name.take(1).uppercase(), text = initials,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontSize = (size.value * 0.4).sp, fontSize = (size.value * 0.4).sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
} }
} }
} }