2 changed files with 49 additions and 273 deletions
@ -1,165 +1,66 @@ |
|||||||
package com.utun.chat.ui.screens |
package com.utun.chat.ui.screens |
||||||
|
|
||||||
import android.provider.OpenableColumns |
|
||||||
import android.util.Log |
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult |
import androidx.activity.compose.rememberLauncherForActivityResult |
||||||
import androidx.activity.result.contract.ActivityResultContracts |
import androidx.activity.result.contract.ActivityResultContracts |
||||||
import androidx.compose.foundation.layout.Column |
import androidx.compose.foundation.layout.* |
||||||
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.lazy.rememberLazyListState |
import androidx.compose.foundation.lazy.rememberLazyListState |
||||||
import androidx.compose.material.icons.Icons |
import androidx.compose.material.icons.Icons |
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack |
import androidx.compose.material.icons.automirrored.filled.ArrowBack |
||||||
import androidx.compose.material.icons.automirrored.filled.Send |
import androidx.compose.material3.* |
||||||
import androidx.compose.material.icons.filled.AttachFile |
import androidx.compose.runtime.* |
||||||
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.ui.Modifier |
import androidx.compose.ui.Modifier |
||||||
import androidx.compose.ui.platform.LocalContext |
import androidx.compose.ui.platform.LocalContext |
||||||
import androidx.compose.ui.unit.dp |
import androidx.compose.ui.unit.dp |
||||||
import com.utun.chat.data.DmConversation |
import com.utun.chat.data.* |
||||||
import com.utun.chat.data.Message |
import com.utun.chat.ui.components.ConversationComposer |
||||||
import com.utun.chat.ui.components.MessageBubble |
import com.utun.chat.ui.components.ConversationMessages |
||||||
|
import com.utun.chat.viewmodel.ChatViewModel |
||||||
import java.io.File |
import java.io.File |
||||||
import kotlinx.coroutines.Dispatchers |
import kotlinx.coroutines.Dispatchers |
||||||
import kotlinx.coroutines.launch |
import kotlinx.coroutines.launch |
||||||
import kotlinx.coroutines.withContext |
import kotlinx.coroutines.withContext |
||||||
|
|
||||||
|
/** PM отличается заголовком и доставкой; ввод, вложения и сообщения общие с каналом. */ |
||||||
@OptIn(ExperimentalMaterial3Api::class) |
@OptIn(ExperimentalMaterial3Api::class) |
||||||
@Composable |
@Composable |
||||||
fun DmChatScreen( |
fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatViewModel, |
||||||
conv: DmConversation, |
onSendMessage: (String) -> Unit, onBack: () -> Unit, |
||||||
messages: List<Message>, |
onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) { |
||||||
onSendMessage: (String) -> Unit, |
val target = ConversationTarget.Pm(conv.convId) |
||||||
onSendFile: (String, String, String) -> Unit, |
val playback by viewModel.playbackState.collectAsState() |
||||||
onBack: () -> Unit |
val state = rememberLazyListState() |
||||||
) { |
|
||||||
var text by remember(conv.convId) { mutableStateOf("") } |
|
||||||
var fileError by remember(conv.convId) { mutableStateOf("") } |
|
||||||
var preparing by remember(conv.convId) { mutableStateOf(false) } |
|
||||||
val context = LocalContext.current |
val context = LocalContext.current |
||||||
val scope = rememberCoroutineScope() |
val scope = rememberCoroutineScope() |
||||||
var savePath by remember { mutableStateOf<String?>(null) } |
var pathToSave by remember { mutableStateOf<String?>(null) } |
||||||
val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> |
var error by remember(conv.convId) { mutableStateOf("") } |
||||||
val path = savePath |
val save = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> |
||||||
savePath = null |
val path = pathToSave |
||||||
|
pathToSave = null |
||||||
if (uri != null && path != null) scope.launch { |
if (uri != null && path != null) scope.launch { |
||||||
try { |
try { |
||||||
withContext(Dispatchers.IO) { |
withContext(Dispatchers.IO) { |
||||||
context.contentResolver.openOutputStream(uri)?.use { output -> File(path).inputStream().use { it.copyTo(output) } } |
context.contentResolver.openOutputStream(uri)?.use { output -> |
||||||
?: error("Не удалось открыть файл для записи") |
File(path).inputStream().use { it.copyTo(output) } |
||||||
|
} ?: throw IllegalStateException("Не удалось открыть файл") |
||||||
} |
} |
||||||
} catch (e: Exception) { Log.e("utun-gui", "PM file save failed", e); fileError = "Не удалось сохранить файл" } |
} catch (e: Exception) { |
||||||
} |
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = "Не удалось сохранить файл" |
||||||
} |
|
||||||
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 } |
|
||||||
} |
} |
||||||
} |
} |
||||||
} |
} |
||||||
val listState = rememberLazyListState() |
|
||||||
|
|
||||||
LaunchedEffect(messages.size) { |
LaunchedEffect(messages.size) { |
||||||
if (messages.lastOrNull()?.isOutgoing == true) { |
if (messages.lastOrNull()?.isOutgoing == true) state.scrollToItem(0) |
||||||
listState.scrollToItem(0) |
|
||||||
} |
|
||||||
} |
} |
||||||
|
Column(Modifier.fillMaxSize().imePadding()) { |
||||||
Column(modifier = Modifier.fillMaxSize().imePadding()) { |
TopAppBar(title = { Text(conv.displayName) }, navigationIcon = { |
||||||
TopAppBar( |
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад") } |
||||||
title = { Text(conv.displayName) }, |
}) |
||||||
navigationIcon = { |
ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(), |
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } |
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) } }, |
||||||
LazyColumn( |
onPlayVideo = onPlayVideo) |
||||||
modifier = Modifier.weight(1f).fillMaxWidth(), |
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) |
||||||
state = listState, |
ConversationComposer(target, viewModel, onSendMessage, onRecordVideo) |
||||||
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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
Loading…
Reference in new issue