From 2d2e4b685ec5cf547f34238489f9a1ea34d9d592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Thu, 30 Apr 2026 10:33:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B3=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chats/data/remote/api/ChatApi.kt | 4 +- .../data/repository/ChatRepositoryImpl.kt | 49 ++- .../chats/data/repository/Mappers.kt | 12 +- .../chat_detail/ChatDetailScreen.kt | 335 ++++++++++++++---- .../chat_detail/ChatDetailViewModel.kt | 40 ++- client-mobile/core/utils/VoiceRecorder.kt | 44 ++- 6 files changed, 379 insertions(+), 105 deletions(-) diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt index 31154bc..0451675 100644 --- a/client-mobile/chats/data/remote/api/ChatApi.kt +++ b/client-mobile/chats/data/remote/api/ChatApi.kt @@ -33,7 +33,7 @@ interface ChatApi { ): List @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 @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( diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index d191716..6859d4d 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -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() + ) } } diff --git a/client-mobile/chats/data/repository/Mappers.kt b/client-mobile/chats/data/repository/Mappers.kt index d1b7d3e..e3ee08e 100644 --- a/client-mobile/chats/data/repository/Mappers.kt +++ b/client-mobile/chats/data/repository/Mappers.kt @@ -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, diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 64b3c30..c2aec5d 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -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(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) + ) + } +} diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 183869e..8930cdb 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -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}" + ) + } } } } diff --git a/client-mobile/core/utils/VoiceRecorder.kt b/client-mobile/core/utils/VoiceRecorder.kt index 4707ef4..ec93383 100644 --- a/client-mobile/core/utils/VoiceRecorder.kt +++ b/client-mobile/core/utils/VoiceRecorder.kt @@ -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 } }