Восстановлены голосовые

This commit is contained in:
Халимов Рустам
2026-04-30 10:33:15 +03:00
parent 46c22300e9
commit 2d2e4b685e
6 changed files with 379 additions and 105 deletions
@@ -55,11 +55,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import androidx.compose.animation.core.*
import androidx.compose.animation.animateContentSize
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import chats.presentation.components.SwipeableMessageItem
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
@@ -69,29 +74,49 @@ fun ChatDetailScreen(
viewModel: ChatDetailViewModel,
onBack: () -> Unit
) {
DisposableEffect(Unit) {
onDispose {
viewModel.clearChatId()
}
}
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val voiceRecorder = remember { VoiceRecorder(context) }
var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
viewModel.clearChatId()
if (isRecording) {
voiceRecorder.stopRecording()
}
}
}
var isEmojiPickerVisible by remember { mutableStateOf(false) }
var recordingOffset by remember { mutableFloatStateOf(0f) }
var isRecordingCanceled by remember { mutableStateOf(false) }
var currentRecordingFile by remember { mutableStateOf<File?>(null) }
var recordingStartTime by remember { mutableLongStateOf(0L) }
var recordingElapsedSeconds by remember { mutableIntStateOf(0) }
val recordingScope = rememberCoroutineScope()
val micPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
currentRecordingFile = file
voiceRecorder.startRecording(file)
isRecording = true
isRecordingCanceled = false
recordingOffset = 0f
recordingStartTime = System.currentTimeMillis()
recordingElapsedSeconds = 0
// Запускаем таймер
recordingScope.launch {
while (isRecording && !isRecordingCanceled) {
delay(1000)
recordingElapsedSeconds++
}
}
}
}
@@ -112,29 +137,6 @@ fun ChatDetailScreen(
}
}
val playNextVoiceMessage = { currentId: String, speed: Float ->
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
if (currentIndex != -1 && currentIndex < state.messages.size - 1) {
val nextVoiceIndexInSublist = state.messages.subList(currentIndex + 1, state.messages.size)
.indexOfFirst { it.mediaType == chats.domain.model.MediaType.AUDIO && it.content.isNullOrEmpty() }
if (nextVoiceIndexInSublist != -1) {
val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist
autoPlayingMessageId = state.messages[actualNextIndex].id
currentPlaybackSpeed = speed
// Прокручиваем к следующему сообщению, иначе оно не распарсится LazyColumn
scope.launch {
listState.animateScrollToItem(actualNextIndex)
}
} else {
autoPlayingMessageId = null
}
} else {
autoPlayingMessageId = null
}
}
// Пикер галереи (Мультивыбор)
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
@@ -181,6 +183,43 @@ fun ChatDetailScreen(
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
}
// Функция воспроизведения следующего голосового сообщения
val playNextVoiceMessage = { currentId: String, speed: Float ->
// Находим текущее сообщение в listItems (который от старых к новым)
val currentIndex = listItems.indexOfFirst {
it is MessageListItem.MessageItem && it.message.id == currentId
}
if (currentIndex != -1 && currentIndex < listItems.size - 1) {
// Ищем следующее голосовое сообщение вперёд по списку (более новое)
val nextVoiceIndexInSublist = listItems.subList(currentIndex + 1, listItems.size)
.indexOfFirst {
it is MessageListItem.MessageItem &&
it.message.mediaType == chats.domain.model.MediaType.AUDIO &&
it.message.content.isNullOrEmpty()
}
if (nextVoiceIndexInSublist != -1) {
val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist
val nextItem = listItems[actualNextIndex] as MessageListItem.MessageItem
autoPlayingMessageId = nextItem.message.id
currentPlaybackSpeed = speed
// Прокручиваем к следующему сообщению, если оно не видно
scope.launch {
val visibleIndexes = listState.layoutInfo.visibleItemsInfo.map { it.index }
if (actualNextIndex !in visibleIndexes) {
listState.animateScrollToItem(actualNextIndex)
}
}
} else {
autoPlayingMessageId = null
}
} else {
autoPlayingMessageId = null
}
}
// Отслеживаем текущую дату для плавающего заголовка
val floatingDate by remember {
derivedStateOf {
@@ -542,8 +581,9 @@ fun ChatDetailScreen(
}
}
// Панель ввода
if (state.selectedMessageIds.isEmpty()) {
// Панель ввода + оверлей записи
Box(modifier = Modifier.fillMaxWidth()) {
// Основная панель
Column(
modifier = Modifier
.fillMaxWidth()
@@ -880,93 +920,122 @@ fun ChatDetailScreen(
// 3. Кнопка действия (в синем квадрате)
val showMic = state.inputText.isEmpty() && state.pendingAttachments.isEmpty()
Surface(
onClick = {
if (!showMic) {
// Анимация пульсации для кнопки записи
val pulseProgress = remember { Animatable(0f) }
LaunchedEffect(isRecording) {
if (isRecording) {
pulseProgress.animateTo(
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000),
repeatMode = RepeatMode.Reverse
)
)
} else {
pulseProgress.snapTo(0f)
}
}
val micScale by animateFloatAsState(
targetValue = if (isRecording) 1f + (pulseProgress.value * 0.15f) else 1f,
label = "micScale"
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(48.dp)
.scale(if (showMic) micScale else 1f)
.clip(RoundedCornerShape(16.dp))
.background(if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary)
.clickable(enabled = !showMic) {
viewModel.sendMessage()
isEmojiPickerVisible = false
}
},
shape = RoundedCornerShape(16.dp),
color = if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(48.dp)
.pointerInput(Unit) {
.pointerInput(showMic) {
if (showMic) {
detectDragGesturesAfterLongPress(
onDragStart = {
android.util.Log.d("ChatDetailScreen", "onDragStart triggered")
val hasMic = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
android.util.Log.d("ChatDetailScreen", "Permission RECORD_AUDIO: $hasMic")
if (hasMic) {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
currentRecordingFile = file
voiceRecorder.startRecording(file)
isRecording = true
isRecordingCanceled = false
recordingOffset = 0f
try {
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
currentRecordingFile = file
android.util.Log.d("ChatDetailScreen", "Starting recording to: ${file.absolutePath}")
voiceRecorder.startRecording(file)
isRecording = true
isRecordingCanceled = false
recordingOffset = 0f
recordingStartTime = System.currentTimeMillis()
recordingElapsedSeconds = 0
// Запускаем таймер
recordingScope.launch {
while (isRecording && !isRecordingCanceled) {
delay(1000)
recordingElapsedSeconds++
}
}
} catch (e: Exception) {
android.util.Log.e("ChatDetailScreen", "Error starting recording", e)
}
} else {
android.util.Log.d("ChatDetailScreen", "Requesting mic permission")
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
onDrag = { change, dragAmount ->
change.consume()
recordingOffset += dragAmount.x
if (recordingOffset < -200f) {
if (recordingOffset < -100f) {
isRecordingCanceled = true
}
},
onDragEnd = {
android.util.Log.d("ChatDetailScreen", "onDragEnd: isRecording=$isRecording, isRecordingCanceled=$isRecordingCanceled")
if (isRecording) {
voiceRecorder.stopRecording()
if (!isRecordingCanceled) {
currentRecordingFile?.let { viewModel.sendVoiceMessage(it) }
currentRecordingFile?.let {
android.util.Log.d("ChatDetailScreen", "Sending voice message file: ${it.absolutePath}")
viewModel.sendVoiceMessage(it)
} ?: android.util.Log.e("ChatDetailScreen", "currentRecordingFile is null")
} else {
android.util.Log.d("ChatDetailScreen", "Recording canceled")
currentRecordingFile?.delete()
}
isRecording = false
isRecordingCanceled = false
recordingOffset = 0f
recordingElapsedSeconds = 0
}
},
onDragCancel = {
android.util.Log.d("ChatDetailScreen", "onDragCancel: isRecording=$isRecording")
if (isRecording) {
voiceRecorder.stopRecording()
currentRecordingFile?.delete()
isRecording = false
isRecordingCanceled = false
recordingOffset = 0f
recordingElapsedSeconds = 0
}
}
)
}
}
) {
Box(contentAlignment = Alignment.Center) {
if (isRecording) {
// Overlay for recording state?
// For now just use the color and icon
}
Icon(
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
// Overlay text for recording cancellation (absolute positioning might be better but let's see)
if (isRecording) {
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Text(
text = if (isRecordingCanceled) "Отменено" else "← Смахните для отмены",
color = if (isRecordingCanceled) Color.Red else Color.Gray,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.Center)
Icon(
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
if (isEmojiPickerVisible) {
LaunchedEffect(Unit) {
@@ -994,6 +1063,87 @@ fun ChatDetailScreen(
viewModel.loadGifCategories()
}
}
}
// Оверлей записи - ПОВЕРХ всего (внутри BoxScope)
if (isRecording) {
val cancelIconScale by animateFloatAsState(
targetValue = if (recordingOffset < -50f) {
1f + ((-recordingOffset - 50f) / 150f).coerceIn(0f, 1f)
} else 0.8f,
label = "cancelScale"
)
val cancelIconAlpha by animateFloatAsState(
targetValue = if (recordingOffset < -50f) 1f else 0.5f,
label = "cancelAlpha"
)
val formattedTime = String.format("%02d:%02d", recordingElapsedSeconds / 60, recordingElapsedSeconds % 60)
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(Color.Black)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.scale(cancelIconScale)
.alpha(cancelIconAlpha),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
color = Color.Red.copy(alpha = 0.3f + (cancelIconAlpha * 0.4f)),
shape = CircleShape
)
)
Icon(
Icons.Default.Close,
contentDescription = "Cancel recording",
tint = Color.Red,
modifier = Modifier.size(32.dp)
)
}
Box(
modifier = Modifier
.background(
color = Color.Red.copy(alpha = 0.3f),
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 20.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
PulseDot()
Spacer(modifier = Modifier.width(10.dp))
Text(
text = formattedTime,
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.size(48.dp))
}
}
}
}
}
}
@@ -1060,3 +1210,44 @@ sealed class MessageListItem {
data class DateHeader(val date: String) : MessageListItem()
object UnreadSeparator : MessageListItem()
}
@Composable
fun PulseDot() {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.5f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
Box(
modifier = Modifier.size(12.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(12.dp)
.background(color = Color.Red, shape = CircleShape)
.alpha(alpha)
)
Box(
modifier = Modifier
.size(8.dp)
.background(color = Color.Red, shape = CircleShape)
)
}
}
@@ -574,7 +574,11 @@ class ChatDetailViewModel @Inject constructor(
}
fun sendVoiceMessage(file: File) {
val chatId = currentChatId ?: return
android.util.Log.d("ChatDetailVM", "sendVoiceMessage called with file: ${file.absolutePath}, size: ${file.length()}")
val chatId = currentChatId ?: run {
android.util.Log.e("ChatDetailVM", "sendVoiceMessage failed: currentChatId is null")
return
}
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
@@ -590,19 +594,21 @@ class ChatDetailViewModel @Inject constructor(
mediaType = chats.domain.model.MediaType.AUDIO,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
// Добавляем временное сообщение в стейт для индикации отправки
_state.update { it.copy(messages = listOf(tempMessage) + it.messages) }
viewModelScope.launch {
try {
android.util.Log.d("ChatDetailVM", "Starting voice upload...")
// 1. Upload the audio file
val url = repository.uploadMedia(file)
android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url")
// 2. Send the message with the attachment
val attachment = chats.data.remote.api.AttachmentRequest(
@@ -612,19 +618,35 @@ class ChatDetailViewModel @Inject constructor(
fileSize = file.length()
)
android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...")
val sentMessage = repository.sendMessage(
chatId = chatId,
content = null,
type = "audio",
type = "media",
attachments = listOf(attachment),
replyToId = replyToId
)
android.util.Log.d("ChatDetailVM", "Voice message sent successfully: ${sentMessage.id}")
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Заменяем временное сообщение на настоящее
_state.update { currentState ->
val filtered = currentState.messages.filter { it.id != tempId }
// Избегаем дубликатов, если SignalR уже добавил сообщение
if (filtered.any { it.id == sentMessage.id }) {
currentState.copy(messages = filtered)
} else {
currentState.copy(messages = listOf(sentMessage) + filtered)
}
}
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
android.util.Log.e("ChatDetailVM", "Error sending voice message", e)
// Удаляем временное сообщение и показываем ошибку
_state.update { currentState ->
currentState.copy(
messages = currentState.messages.filter { it.id != tempId },
error = "Ошибка отправки голосового: ${e.localizedMessage}"
)
}
}
}
}