2 changed files with 144 additions and 0 deletions
@ -0,0 +1,119 @@ |
|||||||
|
package com.utun.chat.ui.components |
||||||
|
|
||||||
|
import android.net.Uri |
||||||
|
import android.provider.OpenableColumns |
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult |
||||||
|
import androidx.activity.result.contract.ActivityResultContracts |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.material3.* |
||||||
|
import androidx.compose.runtime.* |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.platform.LocalContext |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import com.utun.chat.data.ConversationTarget |
||||||
|
import com.utun.chat.data.LogManager |
||||||
|
import com.utun.chat.data.NativeLib |
||||||
|
import com.utun.chat.viewmodel.ChatViewModel |
||||||
|
import java.io.File |
||||||
|
import kotlinx.coroutines.* |
||||||
|
|
||||||
|
private data class SelectedAttachment(val target: ConversationTarget, val uri: Uri, val name: String, val size: Long, val video: Boolean) |
||||||
|
|
||||||
|
/** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */ |
||||||
|
@OptIn(ExperimentalMaterial3Api::class) |
||||||
|
@Composable |
||||||
|
fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, onSendText: (String) -> Unit, |
||||||
|
onRecordVideo: () -> Unit, onCapturePhoto: (() -> Unit)? = null) { |
||||||
|
val context = LocalContext.current |
||||||
|
val scope = rememberCoroutineScope() |
||||||
|
val activeTarget by rememberUpdatedState(target) |
||||||
|
var text by remember(target) { mutableStateOf("") } |
||||||
|
var selected by remember(target) { mutableStateOf<SelectedAttachment?>(null) } |
||||||
|
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) } |
||||||
|
var preparing by remember(target) { mutableStateOf(false) } |
||||||
|
var error by remember(target) { mutableStateOf("") } |
||||||
|
var menu by remember(target) { mutableStateOf(false) } |
||||||
|
var chooseVideo by remember(target) { mutableStateOf(false) } |
||||||
|
var sendAsVideo by remember(target) { mutableStateOf(false) } |
||||||
|
val recording by viewModel.isRecording.collectAsState() |
||||||
|
val locked by viewModel.isRecordingLocked.collectAsState() |
||||||
|
val duration by viewModel.recordingDurationMs.collectAsState() |
||||||
|
val videoMode by viewModel.isVideoMode.collectAsState() |
||||||
|
var level by remember { mutableStateOf(0f) } |
||||||
|
LaunchedEffect(recording) { |
||||||
|
while (recording) { level = NativeLib.voiceGetPeakLevel(); delay(80) } |
||||||
|
level = 0f |
||||||
|
} |
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> |
||||||
|
val pinned = pickerTarget |
||||||
|
pickerTarget = null |
||||||
|
if (uri != null && pinned != null) scope.launch { |
||||||
|
try { |
||||||
|
val attachment = withContext(Dispatchers.IO) { |
||||||
|
var name = "attachment.bin"; var size = 0L |
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { |
||||||
|
if (it.moveToFirst()) { name = it.getString(0) ?: name; size = if (it.isNull(1)) 0 else it.getLong(1) } |
||||||
|
} |
||||||
|
SelectedAttachment(pinned, uri, name, size, context.contentResolver.getType(uri)?.startsWith("video/") == true) |
||||||
|
} |
||||||
|
if (pinned == activeTarget) { selected = attachment; sendAsVideo = false; chooseVideo = attachment.video } |
||||||
|
else LogManager.addLog("INFO", "Attachment", "picker discarded after target change") |
||||||
|
} catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}"); error = "Не удалось открыть вложение" } |
||||||
|
} |
||||||
|
} |
||||||
|
Column { |
||||||
|
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) |
||||||
|
if (preparing) Text("Подготовка вложения…", modifier = Modifier.padding(8.dp)) |
||||||
|
InputBar( |
||||||
|
text = text, onTextChange = { text = it }, enabled = !preparing, |
||||||
|
onSend = { |
||||||
|
val attachment = selected |
||||||
|
val video = sendAsVideo |
||||||
|
selected = null |
||||||
|
if (attachment != null) scope.launch { |
||||||
|
preparing = true; error = "" |
||||||
|
var cache: String? = null |
||||||
|
try { |
||||||
|
withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) } |
||||||
|
val path = cache ?: error("Не удалось скопировать вложение") |
||||||
|
if (video) viewModel.sendVideoMessage(path, attachment.target) |
||||||
|
else viewModel.sendAttachment(path, attachment.target, attachment.name) |
||||||
|
cache = null // Native/backend принял собственный cache-файл. |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}"); error = e.message ?: "Ошибка вложения" } |
||||||
|
finally { |
||||||
|
val abandoned = cache |
||||||
|
if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) { File(abandoned).delete() } |
||||||
|
preparing = false |
||||||
|
} |
||||||
|
} |
||||||
|
if (text.isNotBlank()) { onSendText(text); text = "" } |
||||||
|
}, |
||||||
|
isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level, |
||||||
|
onPttPressed = { viewModel.startVoiceRecording(target) }, |
||||||
|
onPttReleased = { if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage() }, |
||||||
|
onPttLock = { viewModel.lockVoiceRecording() }, onVoiceSend = { viewModel.sendVoiceMessage() }, |
||||||
|
onVoiceCancel = { viewModel.cancelVoiceRecording() }, onAttachClicked = { menu = true }, |
||||||
|
pendingAttachmentName = selected?.name ?: "", pendingAttachmentSize = selected?.size ?: 0, |
||||||
|
onClearAttachment = { selected = null }, isVideoMode = videoMode, |
||||||
|
onToggleMode = { viewModel.toggleVideoMode() }, onVideoRecordOpen = onRecordVideo, |
||||||
|
modifier = Modifier.navigationBarsPadding() |
||||||
|
) |
||||||
|
} |
||||||
|
if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) { |
||||||
|
ListItem(headlineContent = { Text("Выбрать файл") }, modifier = Modifier.clickable { |
||||||
|
menu = false; pickerTarget = target; picker.launch(arrayOf("*/*")) |
||||||
|
}) |
||||||
|
ListItem(headlineContent = { Text("Записать видео") }, modifier = Modifier.clickable { menu = false; onRecordVideo() }) |
||||||
|
if (onCapturePhoto != null) ListItem(headlineContent = { Text("Сфотографировать") }, |
||||||
|
modifier = Modifier.clickable { menu = false; onCapturePhoto() }) |
||||||
|
Spacer(Modifier.height(16.dp)) |
||||||
|
} |
||||||
|
if (chooseVideo) AlertDialog( |
||||||
|
onDismissRequest = { chooseVideo = false }, title = { Text("Отправить видео") }, |
||||||
|
text = { Text("Выбери способ отображения вложения") }, |
||||||
|
confirmButton = { TextButton(onClick = { sendAsVideo = true; chooseVideo = false }) { Text("Как видео") } }, |
||||||
|
dismissButton = { TextButton(onClick = { sendAsVideo = false; chooseVideo = false }) { Text("Как файл") } } |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
package com.utun.chat.ui.components |
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.foundation.lazy.* |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import com.utun.chat.data.ConversationTarget |
||||||
|
import com.utun.chat.data.Message |
||||||
|
import com.utun.chat.viewmodel.ChatViewModel |
||||||
|
|
||||||
|
/** Общий список; направление включено в ключ, поскольку PM имеет два потока seq. */ |
||||||
|
@Composable |
||||||
|
fun ConversationMessages(target: ConversationTarget, messages: List<Message>, state: LazyListState, |
||||||
|
playbackState: ChatViewModel.PlaybackState, modifier: Modifier = Modifier, |
||||||
|
onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit, |
||||||
|
onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit, |
||||||
|
onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {}) { |
||||||
|
LazyColumn(modifier = modifier, state = state, reverseLayout = true) { |
||||||
|
item { Spacer(Modifier.height(8.dp)) } |
||||||
|
items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message -> |
||||||
|
MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue