mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Working image sending
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user