Восстановлены голосовые
This commit is contained in:
@@ -33,7 +33,7 @@ interface ChatApi {
|
|||||||
): List<MessageDto>
|
): List<MessageDto>
|
||||||
|
|
||||||
@POST("messages/chat/{chatId}")
|
@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
|
@Multipart
|
||||||
@POST("messages/upload")
|
@POST("messages/upload")
|
||||||
@@ -68,7 +68,7 @@ interface ChatApi {
|
|||||||
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
|
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
|
||||||
|
|
||||||
@PUT("messages/{messageId}")
|
@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(
|
data class CreatePersonalChatRequest(
|
||||||
|
|||||||
@@ -75,9 +75,43 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
replyToId = replyToId,
|
replyToId = replyToId,
|
||||||
forwardedFromId = forwardedFromId
|
forwardedFromId = forwardedFromId
|
||||||
)
|
)
|
||||||
|
android.util.Log.d("ChatRepoImpl", "sendMessage request: $request")
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
val userId = tokenManager.getUserId() ?: ""
|
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) {
|
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 {
|
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||||
val request = SendMessageRequest(content = content)
|
val request = SendMessageRequest(content = content)
|
||||||
val currentUserId = tokenManager.getUserId() ?: ""
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val returnedId = api.editMessage(messageId, request)
|
||||||
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
|
|
||||||
|
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 {
|
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) {
|
val domainMediaType = when (type) {
|
||||||
"gif" -> MediaType.GIF
|
"gif" -> MediaType.GIF
|
||||||
"image", "photo" -> MediaType.IMAGE
|
"image", "photo" -> MediaType.IMAGE
|
||||||
"video" -> MediaType.VIDEO
|
"video" -> MediaType.VIDEO
|
||||||
"audio", "voice" -> MediaType.AUDIO
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
"file" -> MediaType.FILE
|
"file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE
|
||||||
else -> when (media.firstOrNull()?.type) {
|
else -> when (firstMedia?.type) {
|
||||||
"image", "photo" -> MediaType.IMAGE
|
"image", "photo" -> MediaType.IMAGE
|
||||||
"video" -> MediaType.VIDEO
|
"video" -> MediaType.VIDEO
|
||||||
"audio", "voice" -> MediaType.AUDIO
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
"file" -> MediaType.FILE
|
"file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE
|
||||||
else -> MediaType.TEXT
|
else -> MediaType.TEXT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +55,7 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
|||||||
media = media.map {
|
media = media.map {
|
||||||
chats.domain.model.Media(
|
chats.domain.model.Media(
|
||||||
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
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),
|
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
||||||
filename = it.filename,
|
filename = it.filename,
|
||||||
size = it.size,
|
size = it.size,
|
||||||
|
|||||||
@@ -55,11 +55,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.rememberAsyncImagePainter
|
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 chats.presentation.components.SwipeableMessageItem
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -69,29 +74,49 @@ fun ChatDetailScreen(
|
|||||||
viewModel: ChatDetailViewModel,
|
viewModel: ChatDetailViewModel,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
viewModel.clearChatId()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val voiceRecorder = remember { VoiceRecorder(context) }
|
val voiceRecorder = remember { VoiceRecorder(context) }
|
||||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
|
||||||
var isRecording 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 recordingOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var isRecordingCanceled by remember { mutableStateOf(false) }
|
var isRecordingCanceled by remember { mutableStateOf(false) }
|
||||||
var currentRecordingFile by remember { mutableStateOf<File?>(null) }
|
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(
|
val micPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { isGranted ->
|
) { isGranted ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||||
|
currentRecordingFile = file
|
||||||
voiceRecorder.startRecording(file)
|
voiceRecorder.startRecording(file)
|
||||||
isRecording = true
|
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(
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.PickMultipleVisualMedia()
|
contract = ActivityResultContracts.PickMultipleVisualMedia()
|
||||||
@@ -181,6 +183,43 @@ fun ChatDetailScreen(
|
|||||||
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
|
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 {
|
val floatingDate by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -542,8 +581,9 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Панель ввода
|
// Панель ввода + оверлей записи
|
||||||
if (state.selectedMessageIds.isEmpty()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Основная панель
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -880,71 +920,114 @@ fun ChatDetailScreen(
|
|||||||
// 3. Кнопка действия (в синем квадрате)
|
// 3. Кнопка действия (в синем квадрате)
|
||||||
val showMic = state.inputText.isEmpty() && state.pendingAttachments.isEmpty()
|
val showMic = state.inputText.isEmpty() && state.pendingAttachments.isEmpty()
|
||||||
|
|
||||||
Surface(
|
// Анимация пульсации для кнопки записи
|
||||||
onClick = {
|
val pulseProgress = remember { Animatable(0f) }
|
||||||
if (!showMic) {
|
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()
|
viewModel.sendMessage()
|
||||||
isEmojiPickerVisible = false
|
isEmojiPickerVisible = false
|
||||||
}
|
}
|
||||||
},
|
.pointerInput(showMic) {
|
||||||
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) {
|
|
||||||
if (showMic) {
|
if (showMic) {
|
||||||
detectDragGesturesAfterLongPress(
|
detectDragGesturesAfterLongPress(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "onDragStart triggered")
|
||||||
val hasMic = ContextCompat.checkSelfPermission(
|
val hasMic = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.RECORD_AUDIO
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
android.util.Log.d("ChatDetailScreen", "Permission RECORD_AUDIO: $hasMic")
|
||||||
if (hasMic) {
|
if (hasMic) {
|
||||||
|
try {
|
||||||
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||||
currentRecordingFile = file
|
currentRecordingFile = file
|
||||||
|
android.util.Log.d("ChatDetailScreen", "Starting recording to: ${file.absolutePath}")
|
||||||
voiceRecorder.startRecording(file)
|
voiceRecorder.startRecording(file)
|
||||||
isRecording = true
|
isRecording = true
|
||||||
isRecordingCanceled = false
|
isRecordingCanceled = false
|
||||||
recordingOffset = 0f
|
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 {
|
} else {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "Requesting mic permission")
|
||||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
recordingOffset += dragAmount.x
|
recordingOffset += dragAmount.x
|
||||||
if (recordingOffset < -200f) {
|
if (recordingOffset < -100f) {
|
||||||
isRecordingCanceled = true
|
isRecordingCanceled = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "onDragEnd: isRecording=$isRecording, isRecordingCanceled=$isRecordingCanceled")
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
voiceRecorder.stopRecording()
|
voiceRecorder.stopRecording()
|
||||||
if (!isRecordingCanceled) {
|
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 {
|
} else {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "Recording canceled")
|
||||||
currentRecordingFile?.delete()
|
currentRecordingFile?.delete()
|
||||||
}
|
}
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
isRecordingCanceled = false
|
||||||
recordingOffset = 0f
|
recordingOffset = 0f
|
||||||
|
recordingElapsedSeconds = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
|
android.util.Log.d("ChatDetailScreen", "onDragCancel: isRecording=$isRecording")
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
voiceRecorder.stopRecording()
|
voiceRecorder.stopRecording()
|
||||||
currentRecordingFile?.delete()
|
currentRecordingFile?.delete()
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
isRecordingCanceled = false
|
||||||
recordingOffset = 0f
|
recordingOffset = 0f
|
||||||
|
recordingElapsedSeconds = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
if (isRecording) {
|
|
||||||
// Overlay for recording state?
|
|
||||||
// For now just use the color and icon
|
|
||||||
}
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
|
imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -953,20 +1036,6 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmojiPickerVisible) {
|
if (isEmojiPickerVisible) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -995,6 +1064,87 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Оверлей записи - ПОВЕРХ всего (внутри 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()
|
data class DateHeader(val date: String) : MessageListItem()
|
||||||
object UnreadSeparator : 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) {
|
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
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
_state.update { it.copy(replyingMessage = null) }
|
_state.update { it.copy(replyingMessage = null) }
|
||||||
|
|
||||||
@@ -590,19 +594,21 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
mediaType = chats.domain.model.MediaType.AUDIO,
|
mediaType = chats.domain.model.MediaType.AUDIO,
|
||||||
media = emptyList(),
|
media = emptyList(),
|
||||||
senderName = "Вы",
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
reactions = emptyMap(),
|
reactions = emptyMap(),
|
||||||
isRead = false,
|
isRead = false,
|
||||||
sequenceId = 0
|
sequenceId = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModelScope.launch {
|
// Добавляем временное сообщение в стейт для индикации отправки
|
||||||
repository.saveMessage(tempMessage)
|
_state.update { it.copy(messages = listOf(tempMessage) + it.messages) }
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
android.util.Log.d("ChatDetailVM", "Starting voice upload...")
|
||||||
// 1. Upload the audio file
|
// 1. Upload the audio file
|
||||||
val url = repository.uploadMedia(file)
|
val url = repository.uploadMedia(file)
|
||||||
|
android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url")
|
||||||
|
|
||||||
// 2. Send the message with the attachment
|
// 2. Send the message with the attachment
|
||||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||||
@@ -612,19 +618,35 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
fileSize = file.length()
|
fileSize = file.length()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...")
|
||||||
val sentMessage = repository.sendMessage(
|
val sentMessage = repository.sendMessage(
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
content = null,
|
content = null,
|
||||||
type = "audio",
|
type = "media",
|
||||||
attachments = listOf(attachment),
|
attachments = listOf(attachment),
|
||||||
replyToId = replyToId
|
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) {
|
} catch (e: Exception) {
|
||||||
repository.deleteLocalMessage(tempId)
|
android.util.Log.e("ChatDetailVM", "Error sending voice message", e)
|
||||||
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
|
// Удаляем временное сообщение и показываем ошибку
|
||||||
|
_state.update { currentState ->
|
||||||
|
currentState.copy(
|
||||||
|
messages = currentState.messages.filter { it.id != tempId },
|
||||||
|
error = "Ошибка отправки голосового: ${e.localizedMessage}"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class VoiceRecorder(private val context: Context) {
|
|||||||
private var recorder: MediaRecorder? = null
|
private var recorder: MediaRecorder? = null
|
||||||
|
|
||||||
fun startRecording(outputFile: File) {
|
fun startRecording(outputFile: File) {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("VoiceRecorder", "startRecording to ${outputFile.absolutePath}")
|
||||||
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
MediaRecorder(context)
|
MediaRecorder(context)
|
||||||
} else {
|
} else {
|
||||||
@@ -22,13 +24,25 @@ class VoiceRecorder(private val context: Context) {
|
|||||||
prepare()
|
prepare()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VoiceRecorder", "Failed to start recording", e)
|
||||||
|
recorder?.release()
|
||||||
|
recorder = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopRecording() {
|
fun stopRecording() {
|
||||||
recorder?.apply {
|
try {
|
||||||
stop()
|
android.util.Log.d("VoiceRecorder", "stopRecording")
|
||||||
release()
|
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