diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index dccdf34..19a1f6d 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index d48925e..0074cdd 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index f8ad01b..4a5a6bc 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 21a4947..862107c 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 3d551d8..74725ed 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 4f5865d..826695e 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index ffa79b7..0ce2ce6 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt index 787a2ee..9853b8b 100644 --- a/client-mobile/chats/data/remote/api/ChatApi.kt +++ b/client-mobile/chats/data/remote/api/ChatApi.kt @@ -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) diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt index 5999731..2a38635 100644 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt @@ -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 = emptyList() + @SerializedName("media") val media: List = emptyList(), + @SerializedName("reactions") val reactions: List? = emptyList() +) + +data class ReactionDto( + @SerializedName("emoji") val emoji: String, + @SerializedName("count") val count: Int, + @SerializedName("isSetByMe") val isSetByMe: Boolean ) data class MediaItemDto( diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt index 9bf6e74..e2fe934 100644 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt @@ -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(extraBufferCapacity = 64) val events: SharedFlow = _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() { diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index 1df55e0..8c6c95e 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -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 { 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 { - 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" + } +} + diff --git a/client-mobile/chats/di/ChatModule.kt b/client-mobile/chats/di/ChatModule.kt index 97c0f0c..5f402bc 100644 --- a/client-mobile/chats/di/ChatModule.kt +++ b/client-mobile/chats/di/ChatModule.kt @@ -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 diff --git a/client-mobile/chats/domain/model/Message.kt b/client-mobile/chats/domain/model/Message.kt index 63b55ce..f3cbec6 100644 --- a/client-mobile/chats/domain/model/Message.kt +++ b/client-mobile/chats/domain/model/Message.kt @@ -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, diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index cfc5ca0..4e77737 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -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) { diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 142504a..84ca565 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -19,6 +19,8 @@ import javax.inject.Inject data class ChatDetailState( val messages: List = 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 diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt index 39214a4..a1141b1 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt @@ -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 diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 9c3f4b1..d65ac94 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -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, + 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, 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)) + } + } + } + } +} + diff --git a/client-mobile/core/presentation/components/AppAudioPlayer.kt b/client-mobile/core/presentation/components/AppAudioPlayer.kt index e47bbc7..178274f 100644 --- a/client-mobile/core/presentation/components/AppAudioPlayer.kt +++ b/client-mobile/core/presentation/components/AppAudioPlayer.kt @@ -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 diff --git a/client-mobile/core/presentation/components/AppAvatar.kt b/client-mobile/core/presentation/components/AppAvatar.kt index 72b2837..935c62b 100644 --- a/client-mobile/core/presentation/components/AppAvatar.kt +++ b/client-mobile/core/presentation/components/AppAvatar.kt @@ -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 ) } } } +