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:
parent
dbf7ae662b
commit
11a4c7deda
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue