Чат
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)
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user