Приложение

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,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...
}
}