diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 7755f112..7d187ae5 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -9,17 +9,17 @@ import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.content.MediaType -import androidx.compose.foundation.content.consume -import androidx.compose.foundation.content.contentReceiver -import androidx.compose.foundation.content.hasMediaType +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -39,8 +39,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -56,10 +54,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import app.closer.domain.model.QuestionMessage @@ -148,29 +149,88 @@ private fun EncryptedChatImage( } val loader = rememberMediaImageLoader() val context = LocalContext.current - Box( - modifier = Modifier.widthIn(max = 230.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center - ) { - val b = bytes - if (b != null) { - val model = remember(b) { - ImageRequest.Builder(context).data(ByteBuffer.wrap(b)).build() - } + var showFull by remember { mutableStateOf(false) } + + val b = bytes + if (b == null) { + Box( + modifier = Modifier.size(200.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(22.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + return + } + + val aspect = remember(b) { imageAspectRatio(b) } + val model = remember(b) { ImageRequest.Builder(context).data(ByteBuffer.wrap(b)).build() } + AsyncImage( + model = model, + imageLoader = loader, + contentDescription = "Photo — tap to enlarge", + contentScale = ContentScale.Fit, + modifier = Modifier + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .chatMediaSize(aspect) + .clickable { showFull = true } + ) + + if (showFull) { + FullScreenImageViewer(bytes = b, imageLoader = loader, onDismiss = { showFull = false }) + } +} + +/** Fits the media inside a ~240×320dp bubble at its true aspect ratio (scales small GIFs up to a + * consistent chat size, never squished). */ +private fun Modifier.chatMediaSize(aspect: Float): Modifier { + val maxW = 240f + val maxH = 320f + return if (aspect >= maxW / maxH) this.width(maxW.dp).aspectRatio(aspect) + else this.height(maxH.dp).aspectRatio(aspect) +} + +private fun imageAspectRatio(bytes: ByteArray): Float { + val opts = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + runCatching { android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) } + val w = opts.outWidth + val h = opts.outHeight + return if (w > 0 && h > 0) w.toFloat() / h else 1f +} + +/** Tap-to-enlarge: a full-screen view of the decrypted image (works for photos, GIFs, stickers). */ +@Composable +private fun FullScreenImageViewer( + bytes: ByteArray, + imageLoader: ImageLoader, + onDismiss: () -> Unit +) { + val context = LocalContext.current + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + val model = remember(bytes) { ImageRequest.Builder(context).data(ByteBuffer.wrap(bytes)).build() } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.96f)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { AsyncImage( model = model, - imageLoader = loader, - contentDescription = "Photo", + imageLoader = imageLoader, + contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.widthIn(max = 230.dp) + modifier = Modifier.fillMaxWidth() ) - } else { - Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(22.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp) + ) { + Icon(Icons.Filled.Close, contentDescription = "Close", tint = Color.White) } } } @@ -280,7 +340,6 @@ private fun ChatAvatar(url: String?, visible: Boolean) { * Composer: text + gallery + camera (images only), GIFs/stickers/Bitmoji inserted from the * keyboard (rich content), and hold-free voice notes. All media is E2E-encrypted before sending. */ -@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatComposer( value: String, @@ -418,39 +477,21 @@ fun ChatComposer( Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary) } - OutlinedTextField( - value = value, - onValueChange = onValueChange, - // Accept GIFs / stickers / Bitmoji inserted from the keyboard's pickers. - modifier = Modifier.weight(1f).contentReceiver { transferableContent -> - if (!transferableContent.hasMediaType(MediaType.Image)) { - transferableContent - } else { - transferableContent.consume { item -> - val uri = item.uri - if (uri != null) { readAndSend(uri); true } else false - } - } - }, - placeholder = { - Text( - text = "Message…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) - ) - }, - maxLines = 4, + // Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content). + Surface( + modifier = Modifier.weight(1f), shape = RoundedCornerShape(24.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface - ), - textStyle = MaterialTheme.typography.bodyMedium - ) + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)) + ) { + RichContentTextField( + value = value, + onValueChange = onValueChange, + onImageReceived = { uri -> readAndSend(uri) }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + hint = "Message…" + ) + } if (value.isBlank()) { // Mic when there's nothing typed (tap to record a voice note). diff --git a/app/src/main/java/app/closer/ui/messages/components/RichContentTextField.kt b/app/src/main/java/app/closer/ui/messages/components/RichContentTextField.kt new file mode 100644 index 00000000..ccf13723 --- /dev/null +++ b/app/src/main/java/app/closer/ui/messages/components/RichContentTextField.kt @@ -0,0 +1,89 @@ +package app.closer.ui.messages.components + +import android.net.Uri +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.appcompat.widget.AppCompatEditText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.OnReceiveContentListener +import androidx.core.view.ViewCompat +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.view.inputmethod.InputConnectionCompat + +private val RICH_MIME_TYPES = arrayOf("image/*") + +/** + * A text field that advertises rich-content (image) support to the keyboard, so Gboard's GIF, + * sticker, and Bitmoji tabs can insert straight into the chat. Compose's value-based text fields + * don't reliably set the IME content MIME types, so this wraps an AppCompatEditText and uses the + * canonical AndroidX rich-content APIs — the exact mechanism the keyboard checks before offering + * image insertion. + */ +@Composable +fun RichContentTextField( + value: String, + onValueChange: (String) -> Unit, + onImageReceived: (Uri) -> Unit, + modifier: Modifier = Modifier, + hint: String = "Message…" +) { + val onValueChangeState = rememberUpdatedState(onValueChange) + val onImageState = rememberUpdatedState(onImageReceived) + val textColor = MaterialTheme.colorScheme.onSurface.toArgb() + val hintColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f).toArgb() + + AndroidView( + modifier = modifier, + factory = { ctx -> + object : AppCompatEditText(ctx) { + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val ic = super.onCreateInputConnection(outAttrs) ?: return null + // Tell the IME this field accepts images (GIF / sticker / Bitmoji). + EditorInfoCompat.setContentMimeTypes(outAttrs, RICH_MIME_TYPES) + return InputConnectionCompat.createWrapper(this, ic, outAttrs) + } + }.apply { + background = null + maxLines = 4 + setHint(hint) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {} + override fun onTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {} + override fun afterTextChanged(s: Editable?) { + onValueChangeState.value(s?.toString().orEmpty()) + } + }) + + val listener = OnReceiveContentListener { _, payload -> + val split = payload.partition { item -> item.uri != null } + split.first?.clip?.let { clip -> + for (i in 0 until clip.itemCount) { + clip.getItemAt(i)?.uri?.let { uri -> onImageState.value(uri) } + } + } + split.second + } + ViewCompat.setOnReceiveContentListener(this, RICH_MIME_TYPES, listener) + } + }, + update = { editText -> + // Sync external value changes (e.g. cleared after send) without disturbing typing. + if (editText.text?.toString() != value) { + editText.setText(value) + editText.setSelection(value.length) + } + editText.setTextColor(textColor) + editText.setHintTextColor(hintColor) + } + ) +}