Preserve selected attachment targets and contain composer errors
This commit is contained in:
1 parent
89cf180984
commit
666210c3fb
1 file changed
+49
-18
+49
-18
@@ -1,7 +1,6 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
@@ -15,6 +14,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -27,8 +27,21 @@ 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)
|
||||
private val selectedAttachmentSaver = listSaver<SelectedAttachment?, Any>(save = {
|
||||
if (it == null) emptyList() else listOf(it.target.nativeId, (it.target as? ConversationTarget.Pm)?.generation ?: 0L,
|
||||
it.uri.toString(), it.name, it.size, it.video)
|
||||
}, restore = {
|
||||
if (it.isEmpty()) null else {
|
||||
val id = it[0] as String
|
||||
val target = if (id.startsWith("dm:")) ConversationTarget.Pm(id.removePrefix("dm:"), it[1] as Long)
|
||||
else ConversationTarget.Channel(id)
|
||||
SelectedAttachment(target, Uri.parse(it[2] as String), it[3] as String, it[4] as Long, it[5] as Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
/** Общий ввод и вложения; URI принадлежит экрану, адресат операции неизменяем. */
|
||||
/** Общий ввод и вложения: draft/выбор URI переживают пересоздание Activity, адресат и reply фиксируются до подготовки.
|
||||
* Экран владеет cache до передачи задачи ViewModel; отменённое копирование очищается в NonCancellable.
|
||||
* После ухода с экрана coroutine UI отменяется, уже переданная задача остаётся у ViewModel. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
@@ -58,10 +71,11 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
if (sendFailed) AlertDialog(onDismissRequest = viewModel::dismissMessageSendError,
|
||||
text = { Text(stringResource(R.string.message_send_failed)) },
|
||||
confirmButton = { TextButton(onClick = viewModel::dismissMessageSendError) { Text(stringResource(R.string.close)) } })
|
||||
var selected by remember(target) { mutableStateOf<SelectedAttachment?>(null) }
|
||||
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) }
|
||||
var selected by rememberSaveable(target, stateSaver = selectedAttachmentSaver) { mutableStateOf<SelectedAttachment?>(null) }
|
||||
var pickerId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pickerGeneration by rememberSaveable { mutableStateOf(0L) }
|
||||
var preparing by remember(target) { mutableStateOf(false) }
|
||||
var error by remember(target) { mutableStateOf("") }
|
||||
var error by remember(target) { mutableStateOf<Int?>(null) }
|
||||
var menu by remember(target) { mutableStateOf(false) }
|
||||
var chooseVideo by remember(target) { mutableStateOf(false) }
|
||||
var sendAsVideo by remember(target) { mutableStateOf(false) }
|
||||
@@ -74,18 +88,22 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
val preparation = preparations[target.nativeId]
|
||||
DisposableEffect(target) { onDispose { viewModel.cancelVoiceRecording(target) } }
|
||||
LaunchedEffect(recording) {
|
||||
while (recording) { level = NativeLib.voiceGetPeakLevel(); delay(80) }
|
||||
try { while (recording) { level = NativeLib.voiceGetPeakLevel(); delay(80) } }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "Voice", "Cannot read recording level: $e") }
|
||||
level = 0f
|
||||
}
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
val pinned = pickerTarget
|
||||
pickerTarget = null
|
||||
val pinned = pickerId?.let {
|
||||
if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:"), pickerGeneration) else ConversationTarget.Channel(it)
|
||||
}
|
||||
pickerId = 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) }
|
||||
if (it.moveToFirst()) { name = it.getString(0) ?: name; size = if (it.isNull(1)) 0 else it.getLong(1).coerceAtLeast(0) }
|
||||
}
|
||||
SelectedAttachment(pinned, uri, name, size, context.contentResolver.getType(uri)?.startsWith("video/") == true)
|
||||
}
|
||||
@@ -94,7 +112,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}")
|
||||
error = AppLanguage.text(R.string.attachment_open_failed)
|
||||
error = R.string.attachment_open_failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +133,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
|
||||
error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) }
|
||||
if (preparing || preparation?.jobs?.isNotEmpty() == true) Text(stringResource(R.string.media_preparing), modifier = Modifier.padding(8.dp))
|
||||
else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(8.dp))
|
||||
@@ -130,8 +148,12 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
preparing = true
|
||||
scope.launch {
|
||||
try {
|
||||
error = ""
|
||||
if (!viewModel.saveEdit(edit, sent).await()) error = AppLanguage.text(R.string.edit_failed)
|
||||
error = null
|
||||
if (!viewModel.saveEdit(edit, sent).await()) error = R.string.edit_failed
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "Edit", "Cannot submit edit: $e")
|
||||
error = R.string.edit_failed
|
||||
} finally { preparing = false }
|
||||
}
|
||||
return@InputBar
|
||||
@@ -141,15 +163,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
val sentText = text.text
|
||||
val revision = textRevision
|
||||
val capturedReply = reply
|
||||
preparing = true; error = null
|
||||
scope.launch {
|
||||
preparing = true; error = ""
|
||||
var cache: String? = null
|
||||
var attachmentSaved = attachment == null
|
||||
var textSaved = sentText.isBlank()
|
||||
try {
|
||||
if (attachment != null) {
|
||||
withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) }
|
||||
val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed))
|
||||
val path = cache ?: throw IllegalStateException("Attachment copy failed")
|
||||
val submission = if (video) viewModel.sendVideoMessage(path, attachment.target, capturedReply, clearReply = false)
|
||||
else viewModel.sendAttachment(path, attachment.target, attachment.name, capturedReply)
|
||||
cache = null // Задача ViewModel владеет файлом до завершения отправки.
|
||||
@@ -166,11 +188,16 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}")
|
||||
error = e.message ?: AppLanguage.text(R.string.attachment_send_failed)
|
||||
error = R.string.attachment_send_failed
|
||||
}
|
||||
finally {
|
||||
val abandoned = cache
|
||||
if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) { File(abandoned).delete() }
|
||||
if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) {
|
||||
try {
|
||||
val file = File(abandoned)
|
||||
if (!file.delete() && file.exists()) LogManager.addLog("ERROR", "Attachment", "Abandoned cache removal failed path=$abandoned")
|
||||
} catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "Abandoned cache removal threw path=$abandoned: $e") }
|
||||
}
|
||||
preparing = false
|
||||
}
|
||||
}
|
||||
@@ -188,7 +215,11 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel,
|
||||
}
|
||||
if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) {
|
||||
ListItem(headlineContent = { Text(stringResource(R.string.select_file)) }, modifier = Modifier.clickable {
|
||||
menu = false; pickerTarget = target; picker.launch(arrayOf("*/*"))
|
||||
menu = false; pickerId = target.nativeId; pickerGeneration = (target as? ConversationTarget.Pm)?.generation ?: 0
|
||||
try { picker.launch(arrayOf("*/*")) } catch (e: Exception) {
|
||||
pickerId = null; error = R.string.attachment_open_failed
|
||||
LogManager.addLog("ERROR", "Attachment", "Cannot launch attachment picker: $e")
|
||||
}
|
||||
})
|
||||
ListItem(headlineContent = { Text(stringResource(R.string.record_video)) }, modifier = Modifier.clickable { menu = false; onRecordVideo() })
|
||||
if (onCapturePhoto != null) ListItem(headlineContent = { Text(stringResource(R.string.take_photo)) },
|
||||
|
||||
Reference in new issue
Block a user