Чат
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -51,7 +51,7 @@ interface ChatApi {
|
||||
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
|
||||
|
||||
@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")
|
||||
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||
|
||||
@@ -18,7 +18,14 @@ data class MessageDto(
|
||||
@SerializedName("sequenceId") val sequenceId: Int,
|
||||
@SerializedName("createdAt") val createdAt: String,
|
||||
@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(
|
||||
|
||||
@@ -10,6 +10,9 @@ import chats.data.remote.dto.MessageDto
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -18,6 +21,7 @@ class ChatHubClient @Inject constructor() {
|
||||
private var hubConnection: HubConnection? = null
|
||||
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun connect(baseUrl: String, accessToken: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
|
||||
@@ -30,9 +34,17 @@ class ChatHubClient @Inject constructor() {
|
||||
|
||||
hubConnection?.onClosed { 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() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.model.MediaType
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
@@ -16,21 +17,25 @@ import javax.inject.Inject
|
||||
|
||||
class ChatRepositoryImpl @Inject constructor(
|
||||
private val api: ChatApi,
|
||||
private val tokenManager: TokenManager
|
||||
private val tokenManager: TokenManager,
|
||||
private val serverConfig: ServerConfig
|
||||
) : ChatRepository {
|
||||
|
||||
override suspend fun getChats(): List<Chat> {
|
||||
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> {
|
||||
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 {
|
||||
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) {
|
||||
@@ -56,15 +61,15 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
|
||||
|
||||
// Mappers
|
||||
fun ChatDto.toDomain(currentUserId: String): Chat {
|
||||
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
||||
// Если это личный чат и имя пустое, ищем имя собеседника в списке участников
|
||||
val chatName = name ?: if (type == "personal") {
|
||||
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
|
||||
} else "Group Chat"
|
||||
|
||||
val chatAvatar = avatar ?: if (type == "personal") {
|
||||
val chatAvatar = (avatar ?: if (type == "personal") {
|
||||
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
|
||||
} else null
|
||||
} else null)?.ensureAbsoluteUrl(baseUrl)
|
||||
|
||||
return Chat(
|
||||
id = id,
|
||||
@@ -72,25 +77,48 @@ fun ChatDto.toDomain(currentUserId: String): Chat {
|
||||
name = chatName,
|
||||
avatar = chatAvatar,
|
||||
unreadCount = unreadCount,
|
||||
lastMessage = messages.firstOrNull()?.toDomain()
|
||||
lastMessage = messages.firstOrNull()?.toDomain(baseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
fun MessageDto.toDomain(): Message = Message(
|
||||
id = id,
|
||||
chatId = chatId,
|
||||
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
|
||||
fun MessageDto.toDomain(baseUrl: String): Message {
|
||||
val domainMediaType = when (type) {
|
||||
"image", "photo" -> MediaType.IMAGE
|
||||
"video" -> MediaType.VIDEO
|
||||
"audio" -> MediaType.AUDIO
|
||||
"audio", "voice" -> MediaType.AUDIO
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.ChatRepositoryImpl
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -24,8 +25,8 @@ object ChatModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatRepository(api: ChatApi, tokenManager: TokenManager): ChatRepository {
|
||||
return ChatRepositoryImpl(api, tokenManager)
|
||||
fun provideChatRepository(api: ChatApi, tokenManager: TokenManager, serverConfig: ServerConfig): ChatRepository {
|
||||
return ChatRepositoryImpl(api, tokenManager, serverConfig)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -5,6 +5,7 @@ data class Message(
|
||||
val chatId: String,
|
||||
val senderId: String,
|
||||
val senderName: String,
|
||||
val senderAvatar: String? = null,
|
||||
val content: String?,
|
||||
val sequenceId: Int,
|
||||
val createdAt: String,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package core.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
@@ -21,12 +23,16 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
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
|
||||
fun AppAudioPlayer(
|
||||
url: String,
|
||||
isVoiceMessage: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
contentColor: Color = Color.White
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exoPlayer = remember {
|
||||
@@ -42,6 +48,8 @@ fun AppAudioPlayer(
|
||||
var duration by remember { mutableLongStateOf(0L) }
|
||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
|
||||
val fileName = remember(url) { url.substringAfterLast("/") }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
@@ -60,7 +68,6 @@ fun AppAudioPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление прогресса
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
currentPosition = exoPlayer.currentPosition
|
||||
@@ -68,81 +75,129 @@ fun AppAudioPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.2f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
IconButton(
|
||||
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)
|
||||
)
|
||||
)
|
||||
if (!isVoiceMessage) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = formatDuration(currentPosition),
|
||||
fontSize = 10.sp,
|
||||
color = contentColor.copy(alpha = 0.7f)
|
||||
Icon(
|
||||
Icons.Default.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = contentColor.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = formatDuration(duration),
|
||||
fontSize = 10.sp,
|
||||
color = contentColor.copy(alpha = 0.7f)
|
||||
text = fileName,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVoiceMessage) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
playbackSpeed = when (playbackSpeed) {
|
||||
1.0f -> 1.5f
|
||||
1.5f -> 2.0f
|
||||
else -> 1.0f
|
||||
}
|
||||
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(40.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${playbackSpeed}x",
|
||||
fontSize = 12.sp,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF3390EC),
|
||||
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 {
|
||||
val totalSeconds = durationMs / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -23,14 +24,25 @@ fun AppAvatar(
|
||||
size: Dp = 48.dp,
|
||||
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(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(SoftSquareShape) // Тот самый "мягкий квадрат"
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
|
||||
.clip(SoftSquareShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (url != null) {
|
||||
if (!url.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = name,
|
||||
@@ -38,13 +50,14 @@ fun AppAvatar(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Заглушка, если нет аватара (первая буква имени)
|
||||
Text(
|
||||
text = name.take(1).uppercase(),
|
||||
text = initials,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = (size.value * 0.4).sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user