feat(chat): RichContentTextField for keyboard GIF/sticker/Bitmoji insertion, tap-to-enlarge images

- RichContentTextField: AppCompatEditText wrapper with OnReceiveContentListener for image/* MIME types, enables Gboard GIF/sticker/Bitmoji tabs
- ChatComponents: replace OutlinedTextField with RichContentTextField, remove ExperimentalFoundationApi opt-in, add tap-to-enlarge full-screen image viewer with aspect-ratio sizing
This commit is contained in:
null 2026-06-24 17:37:13 -05:00
parent dbf7ae662b
commit 11a4c7deda
2 changed files with 189 additions and 59 deletions

View File

@ -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).

View File

@ -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)
}
)
}