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.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send 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.Close
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import kotlin.time.Clock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource 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) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun ChatInput( fun ChatInput(
text: String, text: String,
onTextChange: (String) -> Unit, onTextChange: (String) -> Unit,
onSend: (String) -> Unit, onSend: (String, List<SelectedAttachment>) -> Unit,
typingHandler: TypingHandler, typingHandler: TypingHandler,
replyTo: Message? = null, replyTo: Message? = null,
editingMessage: Message? = null, editingMessage: Message? = null,
onClearReply: () -> Unit, onClearReply: () -> Unit,
onClearEdit: () -> Unit, onClearEdit: () -> Unit,
hazeState: HazeState hazeState: HazeState,
recipientId: Int? = null
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } 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) { LaunchedEffect(text) {
if (text.isNotBlank()) { if (text.isNotBlank()) {
typingJob?.cancel() typingJob?.cancel()
typingHandler.sendTyping() typingHandler.sendTyping()
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
typingJob = scope.launch { typingJob = scope.launch {
delay(3000) // 3 seconds delay(3000)
typingHandler.stopTyping() typingHandler.stopTyping()
} }
} else { } else {
@@ -167,6 +237,8 @@ fun ChatInput(
} }
} }
val canSend = text.isNotBlank() || attachments.isNotEmpty()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -208,65 +280,110 @@ fun ChatInput(
) )
} }
OutlinedTextField( AnimatedVisibility(
value = text, visible = attachments.isNotEmpty(),
onValueChange = onTextChange, enter = fadeIn() + expandVertically(),
modifier = Modifier exit = fadeOut() + shrinkVertically()
.fillMaxWidth() ) {
.animateContentSize(), Row(
placeholder = { modifier = Modifier
Text( .fillMaxWidth()
text = stringResource(Res.string.message_placeholder), .horizontalScroll(rememberScrollState())
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) .padding(start = 12.dp, end = 12.dp, top = 8.dp),
) horizontalArrangement = Arrangement.spacedBy(8.dp)
}, ) {
shape = shape, attachments.forEach { attachment ->
maxLines = 5, AttachmentChip(
singleLine = false, attachment = attachment,
colors = OutlinedTextFieldDefaults.colors( onRemove = { attachments = attachments.filter { it.id != attachment.id } }
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)
) )
) { }
Box(Modifier.padding(end = 5.dp)) { }
FilledIconButton( }
onClick = {
onSend(text.trim()) Row(
onTextChange("") modifier = Modifier.fillMaxWidth(),
typingHandler.stopTyping() verticalAlignment = Alignment.Bottom
}, ) {
modifier = Modifier.size(36.dp) if (recipientId != null) {
) { IconButton(onClick = { launchImagePicker() }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, imageVector = Icons.Default.Image,
contentDescription = "Send", contentDescription = "Pick image",
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onSurfaceVariant
modifier = Modifier.size(18.dp) )
) }
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 showCallButton(): Boolean
abstract fun getTypingHandler(): TypingHandler abstract fun getTypingHandler(): TypingHandler
/** DM recipient ID for attachment uploads; null for non-DM panels. */
open fun getRecipientId(): Int? = null
open val showUsernamesInMessages: Boolean open val showUsernamesInMessages: Boolean
get() = true get() = true
} }
@@ -62,6 +62,7 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlin.time.Clock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -69,6 +70,8 @@ import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentUploadJob
import ru.fromchat.api.AttachmentUploadQueue
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.UserStatusStore import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
@@ -426,7 +429,7 @@ fun ChatScreen(
ChatInput( ChatInput(
text = inputText, text = inputText,
onTextChange = { inputText = it }, onTextChange = { inputText = it },
onSend = { text -> onSend = { text, attachments ->
if (editingMessage != null) { if (editingMessage != null) {
scope.launch { scope.launch {
panel.handleEditMessage(editingMessage!!.id, text) panel.handleEditMessage(editingMessage!!.id, text)
@@ -434,7 +437,25 @@ fun ChatScreen(
} }
} else { } else {
scope.launch { 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 replyTo = null
haptic(HapticFeedbackEvent.MessageSent) haptic(HapticFeedbackEvent.MessageSent)
} }
@@ -449,7 +470,8 @@ fun ChatScreen(
editingMessage = null editingMessage = null
inputText = "" inputText = ""
}, },
hazeState = hazeState hazeState = hazeState,
recipientId = panel.getRecipientId()
) )
} }
} }
@@ -208,6 +208,8 @@ class DmPanel(
override fun getTypingHandler(): TypingHandler = typingHandler override fun getTypingHandler(): TypingHandler = typingHandler
override fun getRecipientId(): Int? = otherUserId
override val showUsernamesInMessages: Boolean override val showUsernamesInMessages: Boolean
get() = false 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 */ }
}