Working image sending

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-13 21:45:01 +03:00
Unverified
parent d0a0f472eb
commit a136dd7be3
7 changed files with 301 additions and 62 deletions
@@ -0,0 +1,48 @@
package ru.fromchat.ui.chat
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
actual fun getFilenameFromUri(uri: String): String {
val context = com.pr0gramm3r101.utils.UtilsLibrary.context
context.contentResolver.query(Uri.parse(uri), null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
val name = cursor.getString(nameIndex)
if (!name.isNullOrBlank()) return name
}
}
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
}
@Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris: List<Uri> ->
onResult(uris.map { it.toString() })
}
return {
launcher.launch(
PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly)
.build()
)
}
}
@Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
onResult(uris.map { it.toString() })
}
return {
launcher.launch(arrayOf("*/*"))
}
}
@@ -0,0 +1,30 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
data class SelectedAttachment(
val id: String,
val uri: String,
val filename: String,
val sizeBytes: Long?,
val isImage: Boolean
)
/**
* Platform-specific image picker. Returns a launch function.
* On Android: PickVisualMedia (images only, multiple).
* On iOS: Placeholder (no-op until Phase 4).
*/
@Composable
expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
/**
* Platform-specific file picker. Returns a launch function.
* On Android: OpenDocument/OpenMultipleDocuments (SAF).
* On iOS: Placeholder (no-op until Phase 4).
*/
@Composable
expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit
/** Resolve display filename from content URI. Platform-specific. */
expect fun getFilenameFromUri(uri: String): String
@@ -11,6 +11,7 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -22,13 +23,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,12 +55,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlin.time.Clock
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -135,30 +142,93 @@ private fun PreviewBar(
}
}
@Composable
private fun AttachmentChip(
attachment: SelectedAttachment,
onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = if (attachment.isImage) Icons.Default.Image else Icons.Default.AttachFile,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = attachment.filename,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp)
)
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatInput(
text: String,
onTextChange: (String) -> Unit,
onSend: (String) -> Unit,
onSend: (String, List<SelectedAttachment>) -> Unit,
typingHandler: TypingHandler,
replyTo: Message? = null,
editingMessage: Message? = null,
onClearReply: () -> Unit,
onClearEdit: () -> Unit,
hazeState: HazeState
hazeState: HazeState,
recipientId: Int? = null
) {
val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) }
val launchImagePicker = rememberImagePicker { uris ->
attachments = attachments + uris.map { uri ->
SelectedAttachment(
id = "img_${Clock.System.now().toEpochMilliseconds()}_${attachments.size}",
uri = uri,
filename = getFilenameFromUri(uri),
sizeBytes = null,
isImage = true
)
}
}
val launchFilePicker = rememberFilePicker { uris ->
attachments = attachments + uris.map { uri ->
SelectedAttachment(
id = "file_${Clock.System.now().toEpochMilliseconds()}_${attachments.size}",
uri = uri,
filename = getFilenameFromUri(uri),
sizeBytes = null,
isImage = false
)
}
}
// Handle typing indicator
LaunchedEffect(text) {
if (text.isNotBlank()) {
typingJob?.cancel()
typingHandler.sendTyping()
@Suppress("AssignedValueIsNeverRead")
typingJob = scope.launch {
delay(3000) // 3 seconds
delay(3000)
typingHandler.stopTyping()
}
} else {
@@ -167,6 +237,8 @@ fun ChatInput(
}
}
val canSend = text.isNotBlank() || attachments.isNotEmpty()
Box(
modifier = Modifier
.fillMaxWidth()
@@ -208,65 +280,110 @@ fun ChatInput(
)
}
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
placeholder = {
Text(
text = stringResource(Res.string.message_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = text.isNotBlank(),
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
AnimatedVisibility(
visible = attachments.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
attachments.forEach { attachment ->
AttachmentChip(
attachment = attachment,
onRemove = { attachments = attachments.filter { it.id != attachment.id } }
)
) {
Box(Modifier.padding(end = 5.dp)) {
FilledIconButton(
onClick = {
onSend(text.trim())
onTextChange("")
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom
) {
if (recipientId != null) {
IconButton(onClick = { launchImagePicker() }) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Pick image",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { launchFilePicker() }) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Pick file",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.weight(1f)
.animateContentSize(),
placeholder = {
Text(
text = stringResource(Res.string.message_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = canSend,
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
)
) {
Box(Modifier.padding(end = 5.dp)) {
FilledIconButton(
onClick = {
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onTextChange("")
attachments = emptyList()
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
)
)
}
}
}
}
@@ -317,6 +317,9 @@ abstract class ChatPanel(
abstract fun showCallButton(): Boolean
abstract fun getTypingHandler(): TypingHandler
/** DM recipient ID for attachment uploads; null for non-DM panels. */
open fun getRecipientId(): Int? = null
open val showUsernamesInMessages: Boolean
get() = true
}
@@ -62,6 +62,7 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlin.time.Clock
import kotlinx.coroutines.launch
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
@@ -69,6 +70,8 @@ import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentUploadJob
import ru.fromchat.api.AttachmentUploadQueue
import ru.fromchat.api.Message
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager
@@ -426,7 +429,7 @@ fun ChatScreen(
ChatInput(
text = inputText,
onTextChange = { inputText = it },
onSend = { text ->
onSend = { text, attachments ->
if (editingMessage != null) {
scope.launch {
panel.handleEditMessage(editingMessage!!.id, text)
@@ -434,7 +437,25 @@ fun ChatScreen(
}
} else {
scope.launch {
panel.sendMessageWithImmediateDisplay(text, replyTo?.id)
val replyToId = replyTo?.id
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
AttachmentUploadQueue.enqueue(
AttachmentUploadJob(
jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}",
fileUri = att.uri,
filename = att.filename,
recipientId = recipientId,
plaintext = plaintext.ifBlank { att.filename },
replyToId = replyToId
)
)
}
} else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId)
}
replyTo = null
haptic(HapticFeedbackEvent.MessageSent)
}
@@ -449,7 +470,8 @@ fun ChatScreen(
editingMessage = null
inputText = ""
},
hazeState = hazeState
hazeState = hazeState,
recipientId = panel.getRecipientId()
)
}
}
@@ -208,6 +208,8 @@ class DmPanel(
override fun getTypingHandler(): TypingHandler = typingHandler
override fun getRecipientId(): Int? = otherUserId
override val showUsernamesInMessages: Boolean
get() = false
}
@@ -0,0 +1,17 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
actual fun getFilenameFromUri(uri: String): String {
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
}
@Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
return { /* Phase 4: PHPickerViewController */ }
}
@Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
return { /* Phase 4: UIDocumentPickerViewController */ }
}