diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 09aa3029..4bd15f1b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -56,7 +56,9 @@ import com.utun.chat.data.NativeLib import com.utun.chat.data.RadioAudioEngine import com.utun.chat.data.RadioOverlayService import com.utun.chat.ui.components.InputBar -import com.utun.chat.ui.components.MessageBubble +import com.utun.chat.ui.components.ConversationMessages +import com.utun.chat.ui.components.ConversationComposer +import com.utun.chat.data.ConversationTarget import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -79,13 +81,7 @@ fun ChatScreen( onRecordVideo: () -> Unit = {}, onCapturePhoto: () -> Unit = {} ) { - var text by remember { mutableStateOf("") } - val isVideoMode by viewModel.isVideoMode.collectAsState() val listState = rememberLazyListState() - val isRecording by viewModel.isRecording.collectAsState() - val isRecordingLocked by viewModel.isRecordingLocked.collectAsState() - val recordingDurationMs by viewModel.recordingDurationMs.collectAsState() - var recordingLevel by remember { mutableStateOf(0f) } val context = LocalContext.current val view = LocalView.current val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() } @@ -100,34 +96,6 @@ fun ChatScreen( /* звук сообщений канала */ val soundOn by viewModel.soundOn.collectAsState() - /* attach menu */ - var showAttachMenu by remember { mutableStateOf(false) } - - /* pending attachment state */ - var pendingAttachUri by remember { mutableStateOf(null) } - var pendingAttachName by remember { mutableStateOf("") } - var pendingAttachSize by remember { mutableStateOf(0L) } - - /* file picker */ - val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (uri != null) { - val cr: ContentResolver = context.contentResolver - var name = "file" - var size = 0L - cr.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) - if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name - if (sizeIdx >= 0) size = cursor.getLong(sizeIdx) - } - } - pendingAttachUri = uri - pendingAttachName = name - pendingAttachSize = size - } - } - /* save dialog for downloaded files */ var pendingSaveFilePath by remember { mutableStateOf(null) } val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri -> @@ -142,16 +110,6 @@ fun ChatScreen( } } - /* Poll peak level during recording */ - LaunchedEffect(isRecording) { - if (isRecording) { - while (true) { - recordingLevel = NativeLib.voiceGetPeakLevel() - delay(80) - } - } - } - val playbackState by viewModel.playbackState.collectAsState() /* своё сообщение — подтягиваемся вниз (в reverseLayout индекс 0 — это низ) */ @@ -206,27 +164,15 @@ fun ChatScreen( (windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(), (windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt()) }) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - reverseLayout = true - ) { - item { Spacer(Modifier.height(8.dp)) } - items(messages.reversed()) { msg -> - MessageBubble(msg, playbackState = playbackState, - onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, - onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) }, - onDownloadFile = { msg -> viewModel.downloadAttachment(msg) }, - onOpenFile = { msg -> - if (msg.filePath.isNotEmpty()) { - pendingSaveFilePath = msg.filePath - saveFileLauncher.launch(msg.text) /* suggest original filename */ - } - }, - onOpenImage = { msg -> onViewPhoto(msg) }, - onPlayVideo = { msg -> onPlayVideo(msg) }) - } - } + ConversationMessages( + target = ConversationTarget.Channel(channel.id), messages = messages, state = listState, + playbackState = playbackState, modifier = Modifier.fillMaxSize(), + onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) }, + onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) }, + onDownloadFile = { viewModel.downloadAttachment(it) }, + onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } }, + onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo + ) if (radioActive && !overlayShown) { RadioPttButton( modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), @@ -236,78 +182,7 @@ fun ChatScreen( ) } } - InputBar( - text = text, - onTextChange = { text = it }, - onSend = { - val attachUri = pendingAttachUri - val attachName = pendingAttachName - pendingAttachUri = null - pendingAttachName = "" - pendingAttachSize = 0L - if (attachUri != null) { - scope.launch(Dispatchers.IO) { - val cachePath = viewModel.copyUriToCache(context, attachUri, attachName) - if (cachePath != null) { - viewModel.sendAttachment(cachePath) - } - } - } - if (text.isNotBlank()) { - onSendMessage(text) - text = "" - } - }, - enabled = true, - isRecording = isRecording, - isRecordingLocked = isRecordingLocked, - recordingDurationMs = recordingDurationMs, - recordingLevel = recordingLevel, - onPttPressed = { viewModel.startVoiceRecording() }, - onPttReleased = { - if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage() - }, - onPttLock = { viewModel.lockVoiceRecording() }, - onVoiceSend = { viewModel.sendVoiceMessage() }, - onVoiceCancel = { - viewModel.cancelVoiceRecording() - recordingLevel = 0f - }, - onAttachClicked = { showAttachMenu = true }, - pendingAttachmentName = pendingAttachName, - pendingAttachmentSize = pendingAttachSize, - onClearAttachment = { - pendingAttachUri = null - pendingAttachName = "" - pendingAttachSize = 0L - }, - isVideoMode = isVideoMode, - onToggleMode = { viewModel.toggleVideoMode() }, - onVideoRecordOpen = onRecordVideo, - modifier = Modifier.navigationBarsPadding() - ) - - if (showAttachMenu) { - ModalBottomSheet(onDismissRequest = { showAttachMenu = false }) { - ListItem( - headlineContent = { Text("Выбрать файл") }, - leadingContent = { Text("\uD83D\uDCCE", fontSize = 20.sp) }, - modifier = Modifier.clickable { - showAttachMenu = false - filePickerLauncher.launch("*/*") - } - ) - ListItem( - headlineContent = { Text("Сфотографировать") }, - leadingContent = { Text("\uD83D\uDCF7", fontSize = 20.sp) }, - modifier = Modifier.clickable { - showAttachMenu = false - onCapturePhoto() - } - ) - Spacer(Modifier.height(16.dp)) - } - } + ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onSendMessage, onRecordVideo, onCapturePhoto) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt index 60427c9c..5674bbf1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt @@ -1,165 +1,66 @@ package com.utun.chat.ui.screens -import android.provider.OpenableColumns -import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +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.DmConversation -import com.utun.chat.data.Message -import com.utun.chat.ui.components.MessageBubble +import com.utun.chat.data.* +import com.utun.chat.ui.components.ConversationComposer +import com.utun.chat.ui.components.ConversationMessages +import com.utun.chat.viewmodel.ChatViewModel import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** PM отличается заголовком и доставкой; ввод, вложения и сообщения общие с каналом. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DmChatScreen( - conv: DmConversation, - messages: List, - onSendMessage: (String) -> Unit, - onSendFile: (String, String, String) -> Unit, - onBack: () -> Unit -) { - var text by remember(conv.convId) { mutableStateOf("") } - var fileError by remember(conv.convId) { mutableStateOf("") } - var preparing by remember(conv.convId) { mutableStateOf(false) } +fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatViewModel, + onSendMessage: (String) -> Unit, onBack: () -> Unit, + onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) { + val target = ConversationTarget.Pm(conv.convId) + val playback by viewModel.playbackState.collectAsState() + val state = rememberLazyListState() val context = LocalContext.current val scope = rememberCoroutineScope() - var savePath by remember { mutableStateOf(null) } - val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> - val path = savePath - savePath = null + var pathToSave by remember { mutableStateOf(null) } + var error by remember(conv.convId) { mutableStateOf("") } + val save = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> + val path = pathToSave + pathToSave = null if (uri != null && path != null) scope.launch { try { withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(uri)?.use { output -> File(path).inputStream().use { it.copyTo(output) } } - ?: error("Не удалось открыть файл для записи") + context.contentResolver.openOutputStream(uri)?.use { output -> + File(path).inputStream().use { it.copyTo(output) } + } ?: throw IllegalStateException("Не удалось открыть файл") } - } catch (e: Exception) { Log.e("utun-gui", "PM file save failed", e); fileError = "Не удалось сохранить файл" } - } - } - val attachFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - val targetConv = conv.convId - scope.launch { - preparing = true; fileError = "" - try { - val attachment = withContext(Dispatchers.IO) { - val name = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { - if (it.moveToFirst()) it.getString(0) else null - } ?: "attachment.bin" - val temp = File.createTempFile("pm-upload-", ".file", context.cacheDir) - try { - context.contentResolver.openInputStream(uri)?.use { input -> - temp.outputStream().use { output -> - val buffer = ByteArray(65536) - var size = 0L - while (true) { - val count = input.read(buffer) - if (count < 0) break - size += count - check(size <= 1024L * 1024 * 1024) { "Файл больше 1 ГиБ" } - output.write(buffer, 0, count) - } - } - } ?: error("Не удалось открыть вложение") - temp.absolutePath to name - } catch (e: Exception) { temp.delete(); throw e } - } - onSendFile(targetConv, attachment.first, attachment.second) - } catch (e: Exception) { Log.e("utun-gui", "PM attachment failed", e); fileError = e.message ?: "Ошибка вложения" } - finally { preparing = false } + } catch (e: Exception) { + LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = "Не удалось сохранить файл" } } } - val listState = rememberLazyListState() - LaunchedEffect(messages.size) { - if (messages.lastOrNull()?.isOutgoing == true) { - listState.scrollToItem(0) - } + if (messages.lastOrNull()?.isOutgoing == true) state.scrollToItem(0) } - - Column(modifier = Modifier.fillMaxSize().imePadding()) { - TopAppBar( - title = { Text(conv.displayName) }, - navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } - } - ) - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), - state = listState, - reverseLayout = true - ) { - item { Spacer(Modifier.height(8.dp)) } - items(messages.reversed()) { msg -> - MessageBubble(msg, onOpenFile = { savePath = it.filePath; saveFile.launch(it.text) }) - } - } - if (fileError.isNotEmpty()) Text(fileError, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) - if (preparing) Text("Подготовка вложения…", modifier = Modifier.padding(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .navigationBarsPadding(), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - IconButton(onClick = { attachFile.launch(arrayOf("*/*")) }, enabled = !preparing) { - Icon(Icons.Default.AttachFile, "Прикрепить файл") - } - OutlinedTextField( - value = text, - onValueChange = { text = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Сообщение") }, - maxLines = 4 - ) - Spacer(Modifier.width(8.dp)) - IconButton(onClick = { - if (text.isNotBlank()) { - onSendMessage(text) - text = "" - } - }) { - Icon(Icons.AutoMirrored.Filled.Send, "Send", tint = MaterialTheme.colorScheme.primary) - } - } + Column(Modifier.fillMaxSize().imePadding()) { + TopAppBar(title = { Text(conv.displayName) }, navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад") } + }) + ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(), + onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) }, + onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) }, + onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } }, + onPlayVideo = onPlayVideo) + if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) + ConversationComposer(target, viewModel, onSendMessage, onRecordVideo) } }