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

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

View File

@@ -33,7 +33,7 @@ interface ChatApi {
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): String
@Multipart
@POST("messages/upload")
@@ -68,7 +68,7 @@ interface ChatApi {
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
@PUT("messages/{messageId}")
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): String
}
data class CreatePersonalChatRequest(

View File

@@ -75,9 +75,43 @@ class ChatRepositoryImpl @Inject constructor(
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
android.util.Log.d("ChatRepoImpl", "sendMessage request: $request")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
return try {
val messageId = api.sendMessage(chatId, request)
android.util.Log.d("ChatRepoImpl", "sendMessage response: $messageId")
// Поскольку сервер вернул только ID, создаем заглушку Message.
// Настоящее сообщение придет через SignalR.
Message(
id = messageId,
chatId = chatId,
senderId = userId,
senderName = "", // Будет обновлено через SignalR
content = content,
sequenceId = 0,
createdAt = java.time.ZonedDateTime.now().toString(),
media = attachments?.map {
chats.domain.model.Media(
id = java.util.UUID.randomUUID().toString(),
type = it.type,
url = it.url,
filename = it.fileName,
size = it.fileSize
)
} ?: emptyList(),
mediaType = when(type) {
"image" -> chats.domain.model.MediaType.IMAGE
"video" -> chats.domain.model.MediaType.VIDEO
"audio", "voice" -> chats.domain.model.MediaType.AUDIO
else -> chats.domain.model.MediaType.TEXT
}
)
} catch (e: Exception) {
android.util.Log.e("ChatRepoImpl", "sendMessage error", e)
throw e
}
}
override suspend fun addReaction(messageId: String, emoji: String) {
@@ -145,7 +179,16 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
val returnedId = api.editMessage(messageId, request)
return Message(
id = returnedId,
chatId = "",
senderId = currentUserId,
senderName = "",
content = content,
sequenceId = 0,
createdAt = java.time.ZonedDateTime.now().toString()
)
}
}

View File

@@ -24,17 +24,21 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
}
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
val firstMedia = media.firstOrNull()
val isVoiceFromFilename = firstMedia?.filename?.endsWith(".mp3", ignoreCase = true) == true ||
firstMedia?.filename?.startsWith("voice_", ignoreCase = true) == true
val domainMediaType = when (type) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> when (media.firstOrNull()?.type) {
"file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE
else -> when (firstMedia?.type) {
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
"file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE
else -> MediaType.TEXT
}
}
@@ -51,7 +55,7 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
media = media.map {
chats.domain.model.Media(
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type ?: "unknown",
type = if (isVoiceFromFilename) "voice" else (it.type ?: "unknown"),
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,

View File

@@ -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)
)
}
}

View File

@@ -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}"
)
}
}
}
}

View File

@@ -10,25 +10,39 @@ class VoiceRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
fun startRecording(outputFile: File) {
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(FileOutputStream(outputFile).fd)
prepare()
start()
try {
android.util.Log.d("VoiceRecorder", "startRecording to ${outputFile.absolutePath}")
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(FileOutputStream(outputFile).fd)
prepare()
start()
}
} catch (e: Exception) {
android.util.Log.e("VoiceRecorder", "Failed to start recording", e)
recorder?.release()
recorder = null
}
}
fun stopRecording() {
recorder?.apply {
stop()
release()
try {
android.util.Log.d("VoiceRecorder", "stopRecording")
recorder?.let {
it.stop()
it.release()
android.util.Log.d("VoiceRecorder", "stopRecording success")
}
} catch (e: Exception) {
android.util.Log.e("VoiceRecorder", "Failed to stop recording", e)
} finally {
recorder = null
}
recorder = null
}
}