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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
}
}
}
}

View File

@@ -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

View File

@@ -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
)
}
}
}