Share Android conversation composition attachments and message lists
This commit is contained in:
1 parent
dbec6e75e8
commit
1cd366c0e1
2 files changed
+144
No files matched your search
+119
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user