diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt new file mode 100644 index 00000000..53ff1829 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -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(null) } + var pickerTarget by remember { mutableStateOf(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("Как файл") } } + ) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt new file mode 100644 index 00000000..da63f3fa --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -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, 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) + } + } +}