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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.background
|
||||||
import androidx.compose.foundation.content.MediaType
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.content.consume
|
|
||||||
import androidx.compose.foundation.content.contentReceiver
|
|
||||||
import androidx.compose.foundation.content.hasMediaType
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
|
@ -39,8 +39,6 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -56,10 +54,13 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
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.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import app.closer.domain.model.QuestionMessage
|
import app.closer.domain.model.QuestionMessage
|
||||||
|
|
@ -148,30 +149,89 @@ private fun EncryptedChatImage(
|
||||||
}
|
}
|
||||||
val loader = rememberMediaImageLoader()
|
val loader = rememberMediaImageLoader()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var showFull by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val b = bytes
|
||||||
|
if (b == null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.widthIn(max = 230.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
|
modifier = Modifier.size(200.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val b = bytes
|
|
||||||
if (b != null) {
|
|
||||||
val model = remember(b) {
|
|
||||||
ImageRequest.Builder(context).data(ByteBuffer.wrap(b)).build()
|
|
||||||
}
|
|
||||||
AsyncImage(
|
|
||||||
model = model,
|
|
||||||
imageLoader = loader,
|
|
||||||
contentDescription = "Photo",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier.widthIn(max = 230.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
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 = imageLoader,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
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
|
* 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.
|
* keyboard (rich content), and hold-free voice notes. All media is E2E-encrypted before sending.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatComposer(
|
fun ChatComposer(
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -418,39 +477,21 @@ fun ChatComposer(
|
||||||
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f))
|
||||||
|
) {
|
||||||
|
RichContentTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
// Accept GIFs / stickers / Bitmoji inserted from the keyboard's pickers.
|
onImageReceived = { uri -> readAndSend(uri) },
|
||||||
modifier = Modifier.weight(1f).contentReceiver { transferableContent ->
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
if (!transferableContent.hasMediaType(MediaType.Image)) {
|
hint = "Message…"
|
||||||
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,
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (value.isBlank()) {
|
if (value.isBlank()) {
|
||||||
// Mic when there's nothing typed (tap to record a voice note).
|
// 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