Восстановлены голосовые
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user