Приложение

This commit is contained in:
Халимов Рустам
2026-04-14 01:15:54 +03:00
parent 8399d32490
commit 1fb1be47dd
126 changed files with 6145 additions and 0 deletions
@@ -0,0 +1,73 @@
package chats.data.remote.api
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import retrofit2.http.*
interface ChatApi {
@GET("chats")
suspend fun getChats(): List<ChatDto>
@GET("messages/chat/{chatId}")
suspend fun getMessages(
@Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int? = 50
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(@Path("chatId") chatId: String, @Body content: String): MessageDto
@Multipart
@POST("messages/upload")
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
// Klipy GIF API
@GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): List<KlipyGifDto>
@GET("klipy/search")
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): List<KlipyGifDto>
@GET("klipy/categories")
suspend fun getGifCategories(): List<GifCategoryDto>
@POST("klipy/shared/{id}")
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Body emoji: String)
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@POST("chats/{chatId}/read")
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
}
data class KlipyGifDto(
val id: String,
val images: GifImagesDto?,
val title: String?
)
data class GifImagesDto(
val fixed_height: GifImageSourceDto?,
val original: GifImageSourceDto?
)
data class GifImageSourceDto(
val url: String
)
data class GifCategoryDto(
val category: String,
val preview_url: String,
val query: String
)
data class FileUploadResponse(
val url: String,
val filename: String,
val size: Long
)
@@ -0,0 +1,36 @@
package chats.data.remote.dto
import com.google.gson.annotations.SerializedName
data class UserBasicDto(
@SerializedName("id") val id: String,
@SerializedName("userName") val userName: String,
@SerializedName("displayName") val displayName: String,
@SerializedName("avatarUrl") val avatarUrl: String?
)
data class MessageDto(
@SerializedName("id") val id: String,
@SerializedName("chatId") val chatId: String,
@SerializedName("senderId") val senderId: String,
@SerializedName("content") val content: String?,
@SerializedName("type") val type: String,
@SerializedName("sequenceId") val sequenceId: Int,
@SerializedName("createdAt") val createdAt: String,
@SerializedName("sender") val sender: UserBasicDto,
@SerializedName("media") val media: List<MediaItemDto> = emptyList()
)
data class MediaItemDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("url") val url: String
)
data class ChatDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("name") val name: String?,
@SerializedName("unreadCount") val unreadCount: Int,
@SerializedName("messages") val messages: List<MessageDto>
)
@@ -0,0 +1,31 @@
package chats.data.remote.signalr
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
sealed class ChatEvent {
data class NewMessage(val message: MessageDto) : ChatEvent()
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserOnline(val userId: String) : ChatEvent()
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
data class NewChat(val chat: ChatDto) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String) : ChatEvent()
// Call Events (WebRTC Signaling)
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
data class CallEnded(val chatId: String) : ChatEvent()
// Group Call Events
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
}
@@ -0,0 +1,86 @@
package chats.data.remote.signalr
import android.util.Log
import io.reactivex.rxjava3.core.Single
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub")
.withAccessTokenProvider(Single.just(accessToken))
.build()
setupHandlers()
hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed", exception)
}
hubConnection?.start()?.blockingAwait()
}
private fun setupHandlers() {
hubConnection?.let { conn ->
conn.on("new_message", { message: MessageDto ->
_events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java)
conn.on("messages_read", { chatId: String, userId: String, lastReadSequenceId: Int ->
_events.tryEmit(ChatEvent.MessagesRead(chatId, userId, lastReadSequenceId))
}, String::class.java, String::class.java, Int::class.java)
conn.on("user_typing", { chatId: String, userId: String ->
_events.tryEmit(ChatEvent.UserTyping(chatId, userId))
}, String::class.java, String::class.java)
conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java)
conn.on("new_chat", { chat: ChatDto ->
_events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java)
conn.on("reaction_updated", { messageId: String, chatId: String, userId: String, emoji: String ->
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
}, String::class.java, String::class.java, String::class.java, String::class.java)
// WebRTC Signaling Handlers
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
}, String::class.java, String::class.java, String::class.java, String::class.java)
conn.on("call_answered", { chatId: String, answer: String ->
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
}, String::class.java, String::class.java)
conn.on("ice_candidate", { chatId: String, candidate: String ->
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
}, String::class.java, String::class.java)
conn.on("call_ended", { chatId: String ->
_events.tryEmit(ChatEvent.CallEnded(chatId))
}, String::class.java)
}
}
fun disconnect() {
hubConnection?.stop()
}
}
@@ -0,0 +1,68 @@
package chats.data.repository
import chats.data.remote.api.ChatApi
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository
import javax.inject.Inject
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi
) : ChatRepository {
override suspend fun getChats(): List<Chat> {
return api.getChats().map { it.toDomain() }
}
override suspend fun getMessages(chatId: String): List<Message> {
return api.getMessages(chatId).map { it.toDomain() }
}
override suspend fun sendMessage(chatId: String, content: String): Message {
return api.sendMessage(chatId, content).toDomain()
}
override suspend fun addReaction(messageId: String, emoji: String) {
api.addReaction(messageId, emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) {
api.markMessagesAsRead(chatId, lastMessageId)
}
}
// Mappers
fun ChatDto.toDomain(): Chat = Chat(
id = id,
type = type,
name = name ?: "Unknown Chat",
avatar = null, // Update if avatar is added to ChatDto
unreadCount = unreadCount,
lastMessage = messages.firstOrNull()?.toDomain()
)
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
"video" -> MediaType.VIDEO
"audio" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> MediaType.TEXT
}
)
+35
View File
@@ -0,0 +1,35 @@
package chats.di
import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl
import chats.domain.repository.ChatRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ChatModule {
@Provides
@Singleton
fun provideChatApi(retrofit: Retrofit): ChatApi {
return retrofit.create(ChatApi::class.java)
}
@Provides
@Singleton
fun provideChatRepository(api: ChatApi): ChatRepository {
return ChatRepositoryImpl(api)
}
@Provides
@Singleton
fun provideChatHubClient(): ChatHubClient {
return ChatHubClient()
}
}
@@ -0,0 +1,10 @@
package chats.domain.model
data class Chat(
val id: String,
val type: String,
val name: String,
val avatar: String?,
val unreadCount: Int,
val lastMessage: Message?
)
@@ -0,0 +1,21 @@
package chats.domain.model
data class Message(
val id: String,
val chatId: String,
val senderId: String,
val senderName: String,
val content: String?,
val sequenceId: Int,
val createdAt: String,
val media: List<String> = emptyList(),
val mediaUrl: String? = null,
val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false,
val replyTo: Message? = null
)
enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY
}
@@ -0,0 +1,13 @@
package chats.domain.repository
import chats.domain.model.Chat
import chats.domain.model.Message
interface ChatRepository {
suspend fun getChats(): List<Chat>
suspend fun getMessages(chatId: String): List<Message>
suspend fun sendMessage(chatId: String, content: String): Message
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
}
@@ -0,0 +1,24 @@
package chats.domain.usecase
import chats.data.remote.api.ChatApi
import chats.data.remote.api.FileUploadResponse
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import javax.inject.Inject
class UploadMediaUseCase @Inject constructor(
private val api: ChatApi
) {
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
return try {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
val response = api.uploadFile(body)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,192 @@
package chats.presentation.chat_detail
import android.Manifest
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chats.presentation.components.EmojiPicker
import chats.presentation.components.MessageBubble
import core.utils.VoiceRecorder
import core.utils.copyUriToFile
import kotlinx.coroutines.launch
import java.io.File
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatDetailScreen(
chatName: String,
viewModel: ChatDetailViewModel,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val listState = rememberLazyListState()
val voiceRecorder = remember { VoiceRecorder(context) }
var textInput by remember { mutableStateOf("") }
var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording by remember { mutableStateOf(false) }
// Пикер галереи
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
val file = copyUriToFile(context, it)
file?.let { viewModel.uploadMedia(it) }
}
}
// Автопрокрутка к последнему сообщению
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.size - 1)
}
}
Scaffold(
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
)
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
if (state.canCall) {
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
}
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
}
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Список сообщений
Box(
modifier = Modifier
.weight(1f)
.background(MaterialTheme.colorScheme.background)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.messages) { message ->
MessageBubble(
message = message,
isCurrentUser = message.senderId == "CURRENT_USER_ID" // TODO: Get from Auth
)
}
}
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
// Панель ввода
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.emoji),
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray
)
}
IconButton(onClick = { galleryLauncher.launch("*/*") }) {
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
}
TextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.message_placeholder)) },
maxLines = 4,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
if (textInput.isBlank()) {
IconButton(
onClick = {
if (!isRecording) {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
voiceRecorder.startRecording(file)
isRecording = true
} else {
voiceRecorder.stopRecording()
isRecording = false
// TODO: Send voice file
}
}
) {
Icon(
if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
contentDescription = stringResource(R.string.voice_message),
tint = if (isRecording) Color.Red else Color.Gray
)
}
} else {
IconButton(onClick = {
viewModel.sendMessage(textInput)
textInput = ""
}) {
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
}
}
}
if (isEmojiPickerVisible) {
EmojiPicker(onEmojiSelected = { textInput += it })
}
}
}
}
}
@@ -0,0 +1,151 @@
package chats.presentation.chat_detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.domain.model.Message
import chats.domain.repository.ChatRepository
import chats.data.repository.toDomain
import core.network.ServerConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
data class ChatDetailState(
val messages: List<Message> = emptyList(),
val isLoading: Boolean = false,
val isTyping: Boolean = false,
val typingUser: String? = null,
val error: String? = null,
val canCall: Boolean = true,
val maxFileSize: Long = 100 * 1024 * 1024
)
@HiltViewModel
class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig
) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState())
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
private var currentChatId: String? = null
private var typingTimerJob: Job? = null
private var lastTypingSentTime: Long = 0
init {
val config = serverConfig.getServerConfig()
_state.update { it.copy(
canCall = config.features.calls,
maxFileSize = config.limits.maxFileSize
) }
}
fun setChatId(chatId: String) {
currentChatId = chatId
loadMessages(chatId)
observeSignalREvents()
}
fun loadMessages(chatId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val messages = repository.getMessages(chatId)
_state.update { it.copy(messages = messages, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
}
}
}
private fun observeSignalREvents() {
signalrClient.events
.filter { event ->
when(event) {
is ChatEvent.NewMessage -> event.message.chatId == currentChatId
is ChatEvent.ReactionUpdated -> event.chatId == currentChatId
is ChatEvent.UserTyping -> event.chatId == currentChatId
else -> false
}
}
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
// Avoid adding duplicates if already loaded
_state.update { s ->
val domainMsg = event.message.toDomain()
if (s.messages.none { it.id == domainMsg.id }) {
s.copy(messages = s.messages + domainMsg)
} else s
}
}
is ChatEvent.ReactionUpdated -> {
updateMessageReaction(event.messageId, event.userId, event.emoji)
}
is ChatEvent.UserTyping -> {
_state.update { it.copy(isTyping = true) }
// Reset typing status after some delay would be better,
// but usually server sends stopped_typing event.
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
// Logic to update reactions map.
// Note: Simplified logic, usually we need to know if it was added or removed.
// If we assume reaction_updated is a toggle:
val currentReactions = msg.reactions.toMutableMap()
val count = currentReactions[emoji] ?: 0
// This is a placeholder logic as the exact behavior depends on server implementation.
// For now, let's just increment/decrement based on some convention or just refresh.
currentReactions[emoji] = count + 1
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
fun sendMessage(text: String) {
val chatId = currentChatId ?: return
viewModelScope.launch {
try {
repository.sendMessage(chatId, text)
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
fun addReaction(messageId: String, emoji: String) {
viewModelScope.launch {
try {
repository.addReaction(messageId, emoji)
} catch (e: Exception) {
// Ignore
}
}
}
fun uploadMedia(file: File) {
if (file.length() > _state.value.maxFileSize) {
_state.update { it.copy(error = "File too large") }
return
}
// Upload logic...
}
}
@@ -0,0 +1,120 @@
package chats.presentation.chat_list
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chats.presentation.components.ChatItem
import stories.presentation.StoryViewModel
import stories.presentation.components.StoryThumbnail
import ru.knot.messager.R
@Composable
fun HorizontalDividerComponent(
modifier: Modifier = Modifier,
thickness: androidx.compose.ui.unit.Dp = 1.dp,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
) {
androidx.compose.material3.Divider(
modifier = modifier,
thickness = thickness,
color = color
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatListScreen(
viewModel: ChatListViewModel,
storyViewModel: StoryViewModel,
onChatClick: (String) -> Unit,
onStoryClick: (Int) -> Unit
) {
val state by viewModel.state.collectAsState()
val storyState by storyViewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.chats_title)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (state.isLoading && state.chats.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn {
// Ряд историй сверху списка чатов
item {
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
items(storyState.storyGroups.size) { index ->
val group = storyState.storyGroups[index]
StoryThumbnail(
username = group.username,
avatarUrl = group.avatar,
hasUnseen = true, // В идеале проверяем по статусам
onClick = { onStoryClick(index) }
)
}
}
HorizontalDividerComponent(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
if (state.error != null && state.chats.isEmpty()) {
item {
Text(
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
} else if (state.chats.isEmpty() && !state.isLoading) {
item {
Box(modifier = Modifier.fillParentMaxSize()) {
Text(
text = stringResource(R.string.no_chats_found),
modifier = Modifier.align(Alignment.Center)
)
}
}
} else {
items(state.chats) { chat ->
ChatItem(chat = chat, onClick = onChatClick)
HorizontalDividerComponent(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
}
@@ -0,0 +1,86 @@
package chats.presentation.chat_list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.domain.model.Chat
import chats.domain.repository.ChatRepository
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent
import core.network.ServerConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import chats.data.repository.toDomain
data class ChatListState(
val chats: List<Chat> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isStoriesEnabled: Boolean = true
)
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val repository: ChatRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig
) : ViewModel() {
private val _state = MutableStateFlow(ChatListState())
val state: StateFlow<ChatListState> = _state.asStateFlow()
init {
val isStoriesEnabled = try {
serverConfig.getServerConfig().features.stories
} catch (e: Exception) {
true
}
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
loadChats()
observeSignalREvents()
}
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val chats = repository.getChats()
_state.update { it.copy(chats = chats, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun observeSignalREvents() {
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
updateChatsWithNewMessage(event)
}
is ChatEvent.NewChat -> {
_state.update { it.copy(chats = listOf(event.chat.toDomain()) + it.chats) }
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.message.chatId) {
chat.copy(
lastMessage = event.message.toDomain(),
unreadCount = chat.unreadCount + 1
)
} else chat
}.sortedByDescending { it.lastMessage?.createdAt }
currentState.copy(chats = updatedChats)
}
}
}
@@ -0,0 +1,100 @@
package chats.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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chats.domain.model.Chat
@Composable
fun ChatItem(
chat: Chat,
onClick: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(chat.id) }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Заглушка аватара (в реальном приложении используем Coil для URL)
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Text(
text = chat.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = chat.name,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
chat.lastMessage?.let {
Text(
text = it.createdAt.takeLast(5), // Упрощенный формат времени
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = chat.lastMessage?.content ?: "No messages yet",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (chat.unreadCount > 0) {
Box(
modifier = Modifier
.padding(start = 8.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = chat.unreadCount.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
@@ -0,0 +1,39 @@
package chats.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun EmojiPicker(
onEmojiSelected: (String) -> Unit
) {
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "") // Базовый набор
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(8.dp)
) {
items(emojis) { emoji ->
Box(
modifier = Modifier
.size(48.dp)
.clickable { onEmojiSelected(emoji) },
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 24.sp)
}
}
}
}
@@ -0,0 +1,84 @@
package chats.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import chats.data.remote.api.KlipyGifDto
@Composable
fun KlipyPicker(
gifs: List<KlipyGifDto>,
isLoading: Boolean,
onGifSelected: (String) -> Unit,
onSearch: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
val context = LocalContext.current
val imageLoader = coil.ImageLoader.Builder(context)
.components {
if (android.os.Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
OutlinedTextField(
value = query,
onValueChange = {
query = it
onSearch(it)
},
modifier = Modifier.fillMaxWidth().padding(8.dp),
placeholder = { Text("Search GIFs...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
)
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(4.dp)
) {
items(gifs) { gif ->
val url = gif.images?.fixed_height?.url ?: ""
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.build(),
imageLoader = imageLoader,
contentDescription = gif.title,
modifier = Modifier
.padding(4.dp)
.aspectRatio(1.5f)
.clickable { onGifSelected(url) },
contentScale = ContentScale.Crop
)
}
}
}
}
}
@@ -0,0 +1,78 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.utils.LinkMetadata
@Composable
fun LinkPreview(
metadata: LinkMetadata,
onClick: () -> Unit,
modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(contentColor.copy(alpha = 0.05f))
.clickable(onClick = onClick)
.padding(8.dp)
) {
if (metadata.imageUrl != null) {
AsyncImage(
model = metadata.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
metadata.title?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
metadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
Text(
text = metadata.url.removePrefix("https://").removePrefix("http://").split("/")[0],
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
}
}
@@ -0,0 +1,208 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import chats.domain.model.Message
import chats.domain.model.MediaType
import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
import chats.presentation.components.LinkPreview
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.platform.LocalContext
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: Message,
isCurrentUser: Boolean,
onReactionClick: (String) -> Unit = {}
) {
var showReactionPicker by remember { mutableStateOf(false) }
val backgroundColor = if (isCurrentUser) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = if (isCurrentUser) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else {
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
(message.content == null || message.content.isEmpty())
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = alignment
) {
Column(
modifier = Modifier
.clip(shape)
.background(backgroundColor)
.combinedClickable(
onClick = { /* Handle normal click */ },
onLongClick = { showReactionPicker = true }
)
.padding(8.dp)
.widthIn(max = 300.dp)
) {
if (showReactionPicker) {
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, -100),
onDismissRequest = { showReactionPicker = false }
) {
ReactionPicker(onReactionSelected = {
onReactionClick(it)
showReactionPicker = false
})
}
}
// 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(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp)
) {
Column {
Text(
text = reply.senderName,
style = MaterialTheme.typography.labelSmall,
color = contentColor,
maxLines = 1
)
Text(
text = reply.content ?: "[Media]",
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 1
)
}
}
}
// 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))
}
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 -> {}
}
}
// 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(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = contentColor
)
}
// Time and Status
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
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
)
}
}
}
}
@@ -0,0 +1,61 @@
package chats.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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ReactionPicker(
onReactionSelected: (String) -> Unit
) {
val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
Row(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface, CircleShape)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
reactions.forEach { reaction ->
Text(
text = reaction,
modifier = Modifier
.size(32.dp)
.clickable { onReactionSelected(reaction) },
fontSize = 20.sp
)
}
}
}
@Composable
fun MessageReactions(
reactions: Map<String, Int>, // Эмодзи -> Количество
onReactionClick: (String) -> Unit
) {
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { (emoji, count) ->
Box(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.clickable { onReactionClick(emoji) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(text = "$emoji $count", fontSize = 12.sp)
}
}
}
}