Browse Source

Share Android conversation composition attachments and message lists

master
evgeny 2 days ago
parent
commit
1cd366c0e1
  1. 119
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt
  2. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt

119
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<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("Как файл") } }
)
}

25
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<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…
Cancel
Save