2 changed files with 49 additions and 273 deletions
@ -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<Message>, |
||||
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<Message>, 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<String?>(null) } |
||||
val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> |
||||
val path = savePath |
||||
savePath = null |
||||
var pathToSave by remember { mutableStateOf<String?>(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) |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue