attachments: preview bar + combined send (Android + desktop)

Android: file picker → pending preview bar (name+size+X) →
         combined send with text; cache copy, save dialog for downloads

Desktop: attach stores pending, preview bar before send,
         combined with text in onSendClicked

C: attachment_sender.c — include filename in msg data for display
This commit is contained in:
evgeny committed 2026-08-03 22:36:33 +03:00
1 parent e3df5008da
commit 6a02a16543
7 files changed
+374 -131

No files matched your search

@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Message
@@ -36,7 +37,8 @@ import kotlinx.coroutines.delay
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> },
onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> },
onDownloadFile: (Message) -> Unit = {}) {
onDownloadFile: (Message) -> Unit = {},
onOpenFile: (Message) -> Unit = {}) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@@ -63,10 +65,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
when {
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice)
isMedia -> FileBubble(message, textColor, onDownloadFile)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> {
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Spacer(Modifier.height(2.dp))
Text(message.text, fontSize = 17.sp, color = textColor)
}
}
@@ -171,7 +171,8 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
}
@Composable
private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit = {}) {
private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit = {},
onOpen: (Message) -> Unit = {}) {
val state = message.downloadState
val stateIcon = when {
state == "downloaded" -> "\u2705"
@@ -179,13 +180,18 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
else -> "\u2B07\uFE0F"
}
val stateText = when {
state == "downloaded" -> "Downloaded"
state == "downloaded" -> "Tap to save"
state == "downloading" -> "Downloading..."
else -> "Tap to download"
}
Column(
modifier = Modifier.clickable { if (state.isEmpty()) onDownload(message) }
modifier = Modifier.clickable {
when {
state == "downloaded" -> onOpen(message)
state.isEmpty() -> onDownload(message)
}
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stateIcon, fontSize = 18.sp)
@@ -235,6 +241,9 @@ fun InputBar(
onVoiceSend: () -> Unit = {},
onVoiceCancel: () -> Unit = {},
onAttachClicked: () -> Unit = {},
pendingAttachmentName: String = "",
pendingAttachmentSize: Long = 0L,
onClearAttachment: () -> Unit = {},
modifier: Modifier = Modifier
) {
val ctx = LocalContext.current
@@ -251,126 +260,163 @@ fun InputBar(
hasMicPerm = granted
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> }
val hasAttachment = pendingAttachmentName.isNotEmpty()
Row(
modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
/* Attach button */
if (!isRecording) {
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
Text("\uD83D\uDCCE", fontSize = 20.sp)
}
} else {
Spacer(Modifier.width(4.dp))
}
/* Recording overlay or text input */
if (isRecording) {
Column(modifier = modifier.fillMaxWidth().padding(8.dp)) {
/* Attachment preview bar */
if (hasAttachment) {
Surface(
modifier = Modifier.weight(1f).height(48.dp),
modifier = Modifier.fillMaxWidth().height(36.dp).padding(bottom = 4.dp),
shape = RoundedCornerShape(8.dp),
color = Color(0xFFE53935)
color = MaterialTheme.colorScheme.primaryContainer
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("\uD83D\uDCCE", fontSize = 14.sp)
Spacer(Modifier.width(6.dp))
Text(
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
pendingAttachmentName,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f)
LinearProgressIndicator(
progress = { lvlPct },
modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
)
if (pendingAttachmentSize > 0) {
Text(
formatFileSize(pendingAttachmentSize),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f)
)
Spacer(Modifier.width(8.dp))
}
IconButton(
onClick = onClearAttachment,
modifier = Modifier.size(24.dp)
) {
Text("\u2715", fontSize = 14.sp)
}
}
}
} else {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
enabled = enabled,
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
maxLines = 4
)
}
Spacer(Modifier.width(4.dp))
if (isRecording && isRecordingLocked) {
/* Locked: cancel + send voice */
IconButton(onClick = onVoiceCancel) {
Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935))
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Attach button */
if (!isRecording) {
IconButton(onClick = onAttachClicked, enabled = enabled) {
Text("\uD83D\uDCCE", fontSize = 20.sp)
}
} else {
Spacer(Modifier.width(4.dp))
}
IconButton(onClick = onVoiceSend) {
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
/* Recording overlay or text input */
if (isRecording) {
Surface(
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(8.dp),
color = Color(0xFFE53935)
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
modifier = Modifier.weight(1f)
)
val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f)
LinearProgressIndicator(
progress = { lvlPct },
modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
)
}
}
} else {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
enabled = enabled,
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
maxLines = 4
)
}
} else if (!isRecording && text.isNotBlank()) {
/* Send button — round, primary color */
val sendColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(44.dp)
.background(sendColor, CircleShape)
.clickable { onSend() },
contentAlignment = Alignment.Center
) {
Text("\u27A4", fontSize = 22.sp, color = Color.White)
}
} else {
/* PTT Box — podcast/recording, same identity in idle and holding */
val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent
Box(
modifier = Modifier
.size(48.dp)
.background(pttBg, RoundedCornerShape(4.dp))
.pointerInput(hasMicPerm) {
awaitPointerEventScope {
while (true) {
val down = awaitPointerEvent()
val ch = down.changes.firstOrNull() ?: continue
if (!ch.pressed) continue
ch.consume()
if (!hasMicPerm) {
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
continue
}
onPttPressed()
var locked = false
var totalDragY = 0f
var lastY = ch.position.y
Spacer(Modifier.width(4.dp))
if (isRecording && isRecordingLocked) {
/* Locked: cancel + send voice */
IconButton(onClick = onVoiceCancel) {
Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935))
}
IconButton(onClick = onVoiceSend) {
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
}
} else if (!isRecording && (text.isNotBlank() || hasAttachment)) {
/* Send button — round, primary color */
val sendColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(44.dp)
.background(sendColor, CircleShape)
.clickable { onSend() },
contentAlignment = Alignment.Center
) {
Text("\u27A4", fontSize = 22.sp, color = Color.White)
}
} else {
/* PTT Box — podcast/recording, same identity in idle and holding */
val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent
Box(
modifier = Modifier
.size(48.dp)
.background(pttBg, RoundedCornerShape(4.dp))
.pointerInput(hasMicPerm) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull() ?: break
if (change.pressed) {
val dy = change.position.y - lastY
lastY = change.position.y
totalDragY += dy
if (totalDragY < -lockThresholdPx && !locked) { locked = true; onPttLock() }
change.consume()
} else {
change.consume(); break
val down = awaitPointerEvent()
val ch = down.changes.firstOrNull() ?: continue
if (!ch.pressed) continue
ch.consume()
if (!hasMicPerm) {
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
continue
}
onPttPressed()
var locked = false
var totalDragY = 0f
var lastY = ch.position.y
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull() ?: break
if (change.pressed) {
val dy = change.position.y - lastY
lastY = change.position.y
totalDragY += dy
if (totalDragY < -lockThresholdPx && !locked) { locked = true; onPttLock() }
change.consume()
} else {
change.consume(); break
}
}
onPttReleased()
}
onPttReleased()
}
}
},
contentAlignment = Alignment.Center
) {
Text("\uD83C\uDFA4", fontSize = 20.sp, color = if (isRecording) Color.White else Color.Unspecified)
},
contentAlignment = Alignment.Center
) {
Text("\uD83C\uDFA4", fontSize = 20.sp, color = if (isRecording) Color.White else Color.Unspecified)
}
}
}
}
@@ -1,5 +1,10 @@
package com.utun.chat.ui.screens
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -10,6 +15,7 @@ import androidx.compose.material.icons.filled.People
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.Channel
import com.utun.chat.data.Message
@@ -17,7 +23,10 @@ import com.utun.chat.data.NativeLib
import com.utun.chat.ui.components.InputBar
import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -36,6 +45,47 @@ fun ChatScreen(
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
var recordingLevel by remember { mutableStateOf(0f) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
/* pending attachment state */
var pendingAttachUri by remember { mutableStateOf<Uri?>(null) }
var pendingAttachName by remember { mutableStateOf("") }
var pendingAttachSize by remember { mutableStateOf(0L) }
/* file picker */
val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) {
val cr: ContentResolver = context.contentResolver
var name = "file"
var size = 0L
cr.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name
if (sizeIdx >= 0) size = cursor.getLong(sizeIdx)
}
}
pendingAttachUri = uri
pendingAttachName = name
pendingAttachSize = size
}
}
/* save dialog for downloaded files */
var pendingSaveFilePath by remember { mutableStateOf<String?>(null) }
val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri ->
if (saveUri != null) {
val filePath = pendingSaveFilePath
pendingSaveFilePath = null
if (filePath != null) {
scope.launch(Dispatchers.IO) {
viewModel.saveDownloadedFile(filePath, saveUri)
}
}
}
}
/* Poll peak level during recording */
LaunchedEffect(isRecording) {
@@ -73,7 +123,13 @@ fun ChatScreen(
MessageBubble(messages[idx], playbackState = playbackState,
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) },
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) },
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) })
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) },
onOpenFile = { msg ->
if (msg.filePath.isNotEmpty()) {
pendingSaveFilePath = msg.filePath
saveFileLauncher.launch(msg.text) /* suggest original filename */
}
})
}
item { Spacer(Modifier.height(8.dp)) }
}
@@ -81,8 +137,23 @@ fun ChatScreen(
text = text,
onTextChange = { text = it },
onSend = {
onSendMessage(text)
text = ""
val attachUri = pendingAttachUri
val attachName = pendingAttachName
pendingAttachUri = null
pendingAttachName = ""
pendingAttachSize = 0L
if (attachUri != null) {
scope.launch(Dispatchers.IO) {
val cachePath = viewModel.copyUriToCache(context, attachUri, attachName)
if (cachePath != null) {
viewModel.sendAttachment(cachePath)
}
}
}
if (text.isNotBlank()) {
onSendMessage(text)
text = ""
}
},
enabled = true,
isRecording = isRecording,
@@ -99,7 +170,14 @@ fun ChatScreen(
viewModel.cancelVoiceRecording()
recordingLevel = 0f
},
onAttachClicked = {},
onAttachClicked = { filePickerLauncher.launch("*/*") },
pendingAttachmentName = pendingAttachName,
pendingAttachmentSize = pendingAttachSize,
onClearAttachment = {
pendingAttachUri = null
pendingAttachName = ""
pendingAttachSize = 0L
},
modifier = Modifier.navigationBarsPadding()
)
}
@@ -1,5 +1,8 @@
package com.utun.chat.viewmodel
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -12,6 +15,8 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
class ChatViewModel : ViewModel() {
@@ -443,6 +448,39 @@ class ChatViewModel : ViewModel() {
})
}
fun copyUriToCache(context: Context, uri: Uri, fileName: String): String? {
try {
val cacheDir = File(context.cacheDir, "attachments")
cacheDir.mkdirs()
val cacheFile = File(cacheDir, fileName)
context.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(cacheFile).use { output -> input.copyTo(output) }
} ?: return null
LogManager.addLog("INFO", "VM", "copied attachment to cache: ${cacheFile.absolutePath}")
return cacheFile.absolutePath
} catch (e: Exception) {
LogManager.addLog("ERROR", "VM", "copyUriToCache failed: ${e.message}")
return null
}
}
fun saveDownloadedFile(srcPath: String, saveUri: Uri) {
try {
val src = File(srcPath)
if (!src.exists()) {
LogManager.addLog("ERROR", "VM", "saveDownloadedFile: src not found $srcPath")
return
}
val ctx = ChatApplication.instance
ctx.contentResolver.openOutputStream(saveUri)?.use { output ->
src.inputStream().use { input -> input.copyTo(output) }
}
LogManager.addLog("INFO", "VM", "file saved: $srcPath -> $saveUri")
} catch (e: Exception) {
LogManager.addLog("ERROR", "VM", "saveDownloadedFile failed: ${e.message}")
}
}
fun clearState() {
repo?.close()
repo = null