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