feat(voice+media): voice recording/playback UI, GIF/sticker support, keyboard content receiver
- ChatComponents: voice recording bar (mic → record → send/cancel), voice playback with MediaPlayer, GIF/WebP via coil-gif + ImageDecoderDecoder, keyboard content receiver for stickers/Bitmoji - ConversationViewModel: sendVoice wired through - ConversationScreen: onSendVoice passed to ChatComposer
This commit is contained in:
parent
c20745e82a
commit
3ad725ca8a
|
|
@ -115,6 +115,7 @@ fun ConversationScreen(
|
|||
onValueChange = viewModel::updateMessageInput,
|
||||
onSend = viewModel::sendMessage,
|
||||
onSendImage = viewModel::sendImage,
|
||||
onSendVoice = viewModel::sendVoice,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,13 @@ class ConversationViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun sendVoice(audioBytes: ByteArray, durationMs: Long) {
|
||||
if (currentUserId.isEmpty() || audioBytes.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.sendVoiceMessage(coupleId, conversationId, currentUserId, audioBytes, durationMs) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
|
||||
repository.loadDecryptedMedia(coupleId, mediaUrl)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@ package app.closer.ui.messages.components
|
|||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaPlayer
|
||||
import android.media.MediaRecorder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -22,9 +28,13 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -34,7 +44,10 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -43,24 +56,28 @@ 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.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import app.closer.domain.model.QuestionMessage
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* One chat row — Messenger style: only the partner's avatar shows (on the left); our own messages
|
||||
* are bubbles on the right with no avatar. Handles text and encrypted image messages.
|
||||
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatMessageRow(
|
||||
|
|
@ -86,10 +103,10 @@ fun ChatMessageRow(
|
|||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
if (message.isImage) {
|
||||
EncryptedChatImage(mediaUrl = message.mediaUrl, shape = bubbleShape, loadDecryptedMedia = loadDecryptedMedia)
|
||||
} else {
|
||||
Surface(
|
||||
when {
|
||||
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
||||
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
|
||||
else -> Surface(
|
||||
shape = bubbleShape,
|
||||
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
|
|
@ -107,29 +124,42 @@ fun ChatMessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMediaImageLoader(): ImageLoader {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
|
||||
else add(GifDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EncryptedChatImage(
|
||||
mediaUrl: String,
|
||||
shape: Shape,
|
||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||
) {
|
||||
val image by produceState<ImageBitmap?>(initialValue = null, mediaUrl) {
|
||||
val bytes = loadDecryptedMedia(mediaUrl)
|
||||
value = bytes?.let {
|
||||
runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull()
|
||||
}
|
||||
val bytes by produceState<ByteArray?>(initialValue = null, mediaUrl) {
|
||||
value = loadDecryptedMedia(mediaUrl)
|
||||
}
|
||||
val loader = rememberMediaImageLoader()
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 230.dp)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.widthIn(max = 230.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val bmp = image
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
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)
|
||||
|
|
@ -146,6 +176,82 @@ private fun EncryptedChatImage(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EncryptedVoiceMessage(
|
||||
mediaUrl: String,
|
||||
durationMs: Long,
|
||||
isCurrentUser: Boolean,
|
||||
shape: Shape,
|
||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var player by remember { mutableStateOf<MediaPlayer?>(null) }
|
||||
var tempFile by remember { mutableStateOf<File?>(null) }
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
runCatching { player?.release() }
|
||||
tempFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
if (p.isPlaying) { p.pause(); playing = false } else { p.start(); playing = true }
|
||||
return
|
||||
}
|
||||
if (loading) return
|
||||
loading = true
|
||||
scope.launch {
|
||||
val data = loadDecryptedMedia(mediaUrl)
|
||||
if (data == null) { loading = false; return@launch }
|
||||
val f = withContext(Dispatchers.IO) {
|
||||
File.createTempFile("voice_play", ".m4a", context.cacheDir).apply { writeBytes(data) }
|
||||
}
|
||||
tempFile = f
|
||||
val mp = MediaPlayer()
|
||||
runCatching {
|
||||
mp.setDataSource(f.absolutePath)
|
||||
mp.setOnCompletionListener { playing = false; runCatching { mp.seekTo(0) } }
|
||||
mp.prepare()
|
||||
mp.start()
|
||||
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = shape,
|
||||
color = if (isCurrentUser) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 230.dp)
|
||||
) {
|
||||
val tint = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = { toggle() }, modifier = Modifier.size(36.dp)) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp), color = tint)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (playing) "Pause" else "Play",
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.Mic, contentDescription = null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(16.dp))
|
||||
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatAvatar(url: String?, visible: Boolean) {
|
||||
val size = 28.dp
|
||||
|
|
@ -165,23 +271,23 @@ private fun ChatAvatar(url: String?, visible: Boolean) {
|
|||
modifier = Modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Composer with text + gallery (images-only) + camera. */
|
||||
/**
|
||||
* 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,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onSendImage: (ByteArray) -> Unit,
|
||||
onSendVoice: (ByteArray, Long) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -211,31 +317,103 @@ fun ChatComposer(
|
|||
pendingCameraUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted: Boolean -> if (granted) launchCamera() }
|
||||
|
||||
// ── Voice recording ──────────────────────────────────────────────────────────
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var recordStart by remember { mutableLongStateOf(0L) }
|
||||
var elapsedMs by remember { mutableLongStateOf(0L) }
|
||||
val recorder = remember { mutableStateOf<MediaRecorder?>(null) }
|
||||
val recordFile = remember { mutableStateOf<File?>(null) }
|
||||
|
||||
fun startRecording() {
|
||||
val f = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
|
||||
val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else @Suppress("DEPRECATION") MediaRecorder()
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
rec.setOutputFile(f.absolutePath)
|
||||
runCatching { rec.prepare(); rec.start() }
|
||||
.onSuccess {
|
||||
recorder.value = rec; recordFile.value = f
|
||||
recordStart = System.currentTimeMillis(); elapsedMs = 0L; isRecording = true
|
||||
}
|
||||
.onFailure { runCatching { rec.release() }; f.delete() }
|
||||
}
|
||||
|
||||
fun finishRecording(send: Boolean) {
|
||||
val rec = recorder.value
|
||||
val f = recordFile.value
|
||||
val dur = System.currentTimeMillis() - recordStart
|
||||
runCatching { rec?.stop() }
|
||||
runCatching { rec?.release() }
|
||||
recorder.value = null
|
||||
recordFile.value = null
|
||||
isRecording = false
|
||||
if (send && f != null && dur > 800) {
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) { runCatching { f.readBytes() }.getOrNull() }
|
||||
f.delete()
|
||||
bytes?.takeIf { it.isNotEmpty() }?.let { onSendVoice(it, dur) }
|
||||
}
|
||||
} else {
|
||||
f?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
val micPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted: Boolean -> if (granted) startRecording() }
|
||||
|
||||
LaunchedEffect(isRecording) {
|
||||
while (isRecording) { elapsedMs = System.currentTimeMillis() - recordStart; delay(200) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = { finishRecording(send = false) }, modifier = Modifier.size(44.dp)) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Cancel", tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(MaterialTheme.colorScheme.error))
|
||||
Text(
|
||||
text = "Recording… ${formatDuration(elapsedMs)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { finishRecording(send = true) }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
},
|
||||
modifier = Modifier.size(42.dp)
|
||||
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.size(42.dp)
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
|
@ -243,7 +421,17 @@ fun ChatComposer(
|
|||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
// 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…",
|
||||
|
|
@ -264,13 +452,28 @@ fun ChatComposer(
|
|||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
IconButton(onClick = onSend, enabled = value.isNotBlank(), modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = if (value.isNotBlank()) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
if (value.isBlank()) {
|
||||
// Mic when there's nothing typed (tap to record a voice note).
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
||||
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Long): String {
|
||||
val totalSec = (ms / 1000).toInt()
|
||||
val m = totalSec / 60
|
||||
val s = totalSec % 60
|
||||
return "%d:%02d".format(m, s)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue