From a136dd7be33ea62202718b6c97fd1856b715d59b Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 13 Feb 2026 21:45:01 +0300 Subject: [PATCH] Working image sending Signed-off-by: denis0001-dev --- .../ui/chat/AttachmentPickers.android.kt | 48 ++++ .../ru/fromchat/ui/chat/AttachmentPickers.kt | 30 +++ .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 235 +++++++++++++----- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 3 + .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 28 ++- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 2 + .../fromchat/ui/chat/AttachmentPickers.ios.kt | 17 ++ 7 files changed, 301 insertions(+), 62 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt new file mode 100644 index 0000000..5f7ac54 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt @@ -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) -> Unit): () -> Unit { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia() + ) { uris: List -> + onResult(uris.map { it.toString() }) + } + return { + launcher.launch( + PickVisualMediaRequest.Builder() + .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly) + .build() + ) + } +} + +@Composable +actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments() + ) { uris: List -> + onResult(uris.map { it.toString() }) + } + return { + launcher.launch(arrayOf("*/*")) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt new file mode 100644 index 0000000..82758ea --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt @@ -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) -> 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) -> Unit): () -> Unit + +/** Resolve display filename from content URI. Platform-specific. */ +expect fun getFilenameFromUri(uri: String): String diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index b12e2f7..a785c48 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -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) -> 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(null) } + var attachments by remember { mutableStateOf>(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) + ) + } } } } - } - ) + ) + } } } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 8d58b57..6670164 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 69f7854..00aa941 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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() ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index aabc1d8..464f703 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -208,6 +208,8 @@ class DmPanel( override fun getTypingHandler(): TypingHandler = typingHandler + override fun getRecipientId(): Int? = otherUserId + override val showUsernamesInMessages: Boolean get() = false } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt new file mode 100644 index 0000000..465b0ab --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt @@ -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) -> Unit): () -> Unit { + return { /* Phase 4: PHPickerViewController */ } +} + +@Composable +actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { + return { /* Phase 4: UIDocumentPickerViewController */ } +}