Приложение
This commit is contained in:
@@ -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...
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user