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,
|
onValueChange = viewModel::updateMessageInput,
|
||||||
onSend = viewModel::sendMessage,
|
onSend = viewModel::sendMessage,
|
||||||
onSendImage = viewModel::sendImage,
|
onSendImage = viewModel::sendImage,
|
||||||
|
onSendVoice = viewModel::sendVoice,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
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? =
|
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
|
||||||
repository.loadDecryptedMedia(coupleId, mediaUrl)
|
repository.loadDecryptedMedia(coupleId, mediaUrl)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@ package app.closer.ui.messages.components
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.BitmapFactory
|
import android.media.MediaPlayer
|
||||||
|
import android.media.MediaRecorder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
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.Image
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
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.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
|
||||||
|
|
@ -22,9 +28,13 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.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.Person
|
||||||
import androidx.compose.material.icons.filled.PhotoCamera
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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
|
||||||
|
|
@ -34,7 +44,10 @@ 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
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -43,24 +56,28 @@ 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.ImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
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.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
|
||||||
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
import coil.request.ImageRequest
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
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
|
* 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
|
@Composable
|
||||||
fun ChatMessageRow(
|
fun ChatMessageRow(
|
||||||
|
|
@ -86,10 +103,10 @@ fun ChatMessageRow(
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.isImage) {
|
when {
|
||||||
EncryptedChatImage(mediaUrl = message.mediaUrl, shape = bubbleShape, loadDecryptedMedia = loadDecryptedMedia)
|
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
||||||
} else {
|
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
|
||||||
Surface(
|
else -> Surface(
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.surfaceVariant,
|
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
|
@Composable
|
||||||
private fun EncryptedChatImage(
|
private fun EncryptedChatImage(
|
||||||
mediaUrl: String,
|
mediaUrl: String,
|
||||||
shape: Shape,
|
shape: Shape,
|
||||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||||
) {
|
) {
|
||||||
val image by produceState<ImageBitmap?>(initialValue = null, mediaUrl) {
|
val bytes by produceState<ByteArray?>(initialValue = null, mediaUrl) {
|
||||||
val bytes = loadDecryptedMedia(mediaUrl)
|
value = loadDecryptedMedia(mediaUrl)
|
||||||
value = bytes?.let {
|
|
||||||
runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val loader = rememberMediaImageLoader()
|
||||||
|
val context = LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.widthIn(max = 230.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
.widthIn(max = 230.dp)
|
|
||||||
.clip(shape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val bmp = image
|
val b = bytes
|
||||||
if (bmp != null) {
|
if (b != null) {
|
||||||
Image(
|
val model = remember(b) {
|
||||||
bitmap = bmp,
|
ImageRequest.Builder(context).data(ByteBuffer.wrap(b)).build()
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
model = model,
|
||||||
|
imageLoader = loader,
|
||||||
contentDescription = "Photo",
|
contentDescription = "Photo",
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.widthIn(max = 230.dp)
|
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
|
@Composable
|
||||||
private fun ChatAvatar(url: String?, visible: Boolean) {
|
private fun ChatAvatar(url: String?, visible: Boolean) {
|
||||||
val size = 28.dp
|
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),
|
modifier = Modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
||||||
imageVector = 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
|
@Composable
|
||||||
fun ChatComposer(
|
fun ChatComposer(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onSendImage: (ByteArray) -> Unit,
|
onSendImage: (ByteArray) -> Unit,
|
||||||
|
onSendVoice: (ByteArray, Long) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -211,31 +317,103 @@ fun ChatComposer(
|
||||||
pendingCameraUri = uri
|
pendingCameraUri = uri
|
||||||
cameraLauncher.launch(uri)
|
cameraLauncher.launch(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission()
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
) { granted: Boolean -> if (granted) launchCamera() }
|
) { 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(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||||
galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
modifier = Modifier.size(40.dp)
|
||||||
},
|
|
||||||
modifier = Modifier.size(42.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||||
== PackageManager.PERMISSION_GRANTED
|
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
) 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)
|
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +421,17 @@ fun ChatComposer(
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
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 = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = "Message…",
|
text = "Message…",
|
||||||
|
|
@ -264,13 +452,28 @@ fun ChatComposer(
|
||||||
textStyle = MaterialTheme.typography.bodyMedium
|
textStyle = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
|
|
||||||
IconButton(onClick = onSend, enabled = value.isNotBlank(), modifier = Modifier.size(48.dp)) {
|
if (value.isBlank()) {
|
||||||
Icon(
|
// Mic when there's nothing typed (tap to record a voice note).
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
IconButton(
|
||||||
contentDescription = "Send",
|
onClick = {
|
||||||
tint = if (value.isNotBlank()) MaterialTheme.colorScheme.primary
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
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