feat(conversations): UI — Messages inbox, conversation screen, view models, chat components

- MessagesInboxScreen: list of conversations with last-message preview, tap to open
- MessagesInboxViewModel: observes conversations from Firestore
- ConversationScreen: E2E-encrypted messaging with send/receive, image support
- ConversationViewModel: send message, observe messages, active conversation tracking
- ChatComponents: reusable message bubble, input bar, encrypted image rendering
This commit is contained in:
null 2026-06-24 16:13:52 -05:00
parent db5b8a5f8a
commit 33baf220e4
5 changed files with 798 additions and 0 deletions

View File

@ -0,0 +1,142 @@
package app.closer.ui.messages
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.ui.messages.components.ChatComposer
import app.closer.ui.messages.components.ChatMessageRow
import app.closer.ui.theme.closerBackgroundBrush
import coil.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationScreen(
onNavigate: (String) -> Unit = {},
viewModel: ConversationViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
}
Scaffold(
containerColor = Color.Transparent,
modifier = Modifier.background(closerBackgroundBrush()),
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
ConversationAvatar(state.partnerPhotoUrl)
Text(
text = state.title,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp),
color = MaterialTheme.colorScheme.onSurface
)
}
},
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.navigationBarsPadding()
) {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message ->
val isMe = message.userId == viewModel.currentUserId
val showAvatar = index == state.messages.lastIndex ||
state.messages[index + 1].userId != message.userId
ChatMessageRow(
message = message,
isCurrentUser = isMe,
partnerAvatarUrl = state.partnerPhotoUrl,
showAvatar = showAvatar,
loadDecryptedMedia = viewModel::loadDecryptedMedia
)
}
}
ChatComposer(
value = state.messageInput,
onValueChange = viewModel::updateMessageInput,
onSend = viewModel::sendMessage,
onSendImage = viewModel::sendImage,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
}
}
@Composable
private fun ConversationAvatar(url: String?) {
val size = 32.dp
if (!url.isNullOrBlank()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).clip(CircleShape)
)
} else {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size).clip(CircleShape)
)
}
}

View File

@ -0,0 +1,118 @@
package app.closer.ui.messages
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.data.local.QuestionDao
import app.closer.data.local.mapper.toQuestion
import app.closer.domain.model.QuestionMessage
import app.closer.domain.repository.ConversationRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository
import app.closer.notifications.ActiveThreadMonitor
import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ConversationUiState(
val title: String = "",
val partnerPhotoUrl: String? = null,
val messages: List<QuestionMessage> = emptyList(),
val messageInput: String = "",
val isLoading: Boolean = true
)
@HiltViewModel
class ConversationViewModel @Inject constructor(
private val repository: ConversationRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val questionDao: QuestionDao,
private val activeThreadMonitor: ActiveThreadMonitor,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val coupleId: String = savedStateHandle["coupleId"] ?: ""
private val conversationId: String = savedStateHandle["conversationId"] ?: ""
val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: ""
private val _uiState = MutableStateFlow(ConversationUiState())
val uiState: StateFlow<ConversationUiState> = _uiState.asStateFlow()
init {
// Reading this conversation suppresses its bubble + clears its unread.
activeThreadMonitor.enter(conversationId)
load()
}
override fun onCleared() {
super.onCleared()
activeThreadMonitor.leave(conversationId)
}
private fun load() {
viewModelScope.launch {
if (conversationId == "main") {
runCatching { repository.ensureMainConversation(coupleId) }
} else if (conversationId.startsWith("q_")) {
runCatching { repository.ensureQuestionConversation(coupleId, conversationId.removePrefix("q_")) }
}
// Title + partner avatar.
val couple = runCatching { coupleRepository.getCoupleForUser(currentUserId) }.getOrNull()
val partnerId = couple?.userIds?.firstOrNull { it != currentUserId }
val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
val title = if (conversationId.startsWith("q_")) {
val questionId = conversationId.removePrefix("q_")
runCatching { questionDao.getQuestionById(questionId)?.toQuestion()?.text }.getOrNull()
?: "Discussion"
} else {
partner?.displayName?.takeIf { it.isNotBlank() } ?: "Your conversation"
}
_uiState.update {
it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false)
}
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
launch {
repository.observeMessages(coupleId, conversationId).collect { msgs ->
_uiState.update { it.copy(messages = msgs) }
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
}
}
}
}
fun updateMessageInput(text: String) {
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
}
fun sendMessage() {
val text = _uiState.value.messageInput.trim()
if (text.isBlank() || currentUserId.isEmpty()) return
_uiState.update { it.copy(messageInput = "") }
viewModelScope.launch {
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
}
}
fun sendImage(imageBytes: ByteArray) {
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
viewModelScope.launch {
runCatching { repository.sendImageMessage(coupleId, conversationId, currentUserId, imageBytes) }
}
}
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
repository.loadDecryptedMedia(coupleId, mediaUrl)
companion object {
const val MAX_MESSAGE_LENGTH = 2000
}
}

View File

@ -0,0 +1,179 @@
package app.closer.ui.messages
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.Conversation
import app.closer.ui.theme.CloserPalette
import coil.compose.AsyncImage
import java.util.concurrent.TimeUnit
@Composable
fun MessagesInboxScreen(
onNavigate: (String) -> Unit = {},
viewModel: MessagesInboxViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 4.dp)
) {
Text(
text = "Messages",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp)
)
if (!state.isPaired) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Invite your partner to start chatting.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = { onNavigate(AppRoute.CREATE_INVITE) }, modifier = Modifier.padding(top = 16.dp)) {
Text("Invite partner")
}
}
return
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(state.conversations, key = { it.id }) { conv ->
ConversationRow(
conversation = conv,
partnerPhotoUrl = state.partnerPhotoUrl,
onClick = { onNavigate(AppRoute.conversation(state.coupleId, conv.id)) }
)
}
}
}
}
@Composable
private fun ConversationRow(
conversation: Conversation,
partnerPhotoUrl: String?,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Partner avatar (the chat is with the partner in every conversation).
if (!partnerPhotoUrl.isNullOrBlank()) {
AsyncImage(
model = partnerPhotoUrl,
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
modifier = Modifier.size(52.dp).clip(CircleShape)
)
} else {
Box(
modifier = Modifier.size(52.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Column(
modifier = Modifier.weight(1f).padding(start = 14.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = conversation.title.ifBlank { "Conversation" },
style = MaterialTheme.typography.titleMedium,
fontWeight = if (conversation.unread) FontWeight.Bold else FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = conversation.lastMessagePreview.ifBlank {
if (conversation.isMain) "Start the conversation" else "Tap to discuss"
},
style = MaterialTheme.typography.bodyMedium,
color = if (conversation.unread) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (conversation.unread) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (conversation.lastMessageAt > 0L) {
Text(
text = relativeTime(conversation.lastMessageAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (conversation.unread) {
Box(
modifier = Modifier.size(10.dp).clip(CircleShape).background(CloserPalette.PinkBright)
)
}
}
}
}
private fun relativeTime(epochMillis: Long): String {
val diff = System.currentTimeMillis() - epochMillis
val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
val hours = TimeUnit.MILLISECONDS.toHours(diff)
val days = TimeUnit.MILLISECONDS.toDays(diff)
return when {
minutes < 1 -> "now"
minutes < 60 -> "${minutes}m"
hours < 24 -> "${hours}h"
days < 7 -> "${days}d"
else -> {
val fmt = java.text.SimpleDateFormat("MMM d", java.util.Locale.getDefault())
fmt.format(java.util.Date(epochMillis))
}
}
}

View File

@ -0,0 +1,83 @@
package app.closer.ui.messages
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.data.local.QuestionDao
import app.closer.data.local.mapper.toQuestion
import app.closer.domain.model.Conversation
import app.closer.domain.repository.ConversationRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MessagesInboxUiState(
val isLoading: Boolean = true,
val coupleId: String = "",
val isPaired: Boolean = true,
val partnerPhotoUrl: String? = null,
val conversations: List<Conversation> = emptyList()
)
@HiltViewModel
class MessagesInboxViewModel @Inject constructor(
private val repository: ConversationRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val questionDao: QuestionDao
) : ViewModel() {
private val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: ""
private val _uiState = MutableStateFlow(MessagesInboxUiState())
val uiState: StateFlow<MessagesInboxUiState> = _uiState.asStateFlow()
init { load() }
private fun load() {
viewModelScope.launch {
val couple = runCatching { coupleRepository.getCoupleForUser(currentUserId) }.getOrNull()
if (couple == null) {
_uiState.update { it.copy(isLoading = false, isPaired = false) }
return@launch
}
val coupleId = couple.id
val partnerId = couple.userIds.firstOrNull { it != currentUserId }
val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
val partnerName = partner?.displayName?.takeIf { it.isNotBlank() } ?: "Your partner"
// Show the pinned main conversation immediately so the inbox is never blank while the
// live read warms up.
val mainEntry = Conversation(id = "main", type = "main", title = partnerName)
_uiState.update {
it.copy(coupleId = coupleId, partnerPhotoUrl = partner?.photoUrl, isLoading = false, conversations = listOf(mainEntry))
}
runCatching { repository.ensureMainConversation(coupleId) }
repository.observeConversations(coupleId, currentUserId).collect { convs ->
val titled = buildList {
for (c in convs) {
val title = if (c.isMain) {
partnerName
} else {
runCatching { questionDao.getQuestionById(c.questionId)?.toQuestion()?.text }
.getOrNull() ?: "Discussion"
}
add(c.copy(title = title))
}
}
// Pin the main conversation on top; the rest by most-recent activity.
val main = titled.firstOrNull { it.isMain }
?: Conversation(id = "main", type = "main", title = partnerName)
val rest = titled.filterNot { it.isMain }.sortedByDescending { it.lastMessageAt }
_uiState.update { it.copy(isLoading = false, conversations = listOf(main) + rest) }
}
}
}
}

View File

@ -0,0 +1,276 @@
package app.closer.ui.messages.components
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
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.background
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.Image
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoCamera
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* 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.
*/
@Composable
fun ChatMessageRow(
message: QuestionMessage,
isCurrentUser: Boolean,
partnerAvatarUrl: String?,
showAvatar: Boolean,
loadDecryptedMedia: suspend (String) -> ByteArray?
) {
val bubbleShape = if (isCurrentUser) {
RoundedCornerShape(topStart = 16.dp, topEnd = 4.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
} else {
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (!isCurrentUser) {
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
Spacer(modifier = Modifier.width(6.dp))
}
if (message.isImage) {
EncryptedChatImage(mediaUrl = message.mediaUrl, shape = bubbleShape, loadDecryptedMedia = loadDecryptedMedia)
} else {
Surface(
shape = bubbleShape,
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 264.dp)
) {
Text(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
)
}
}
}
}
@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()
}
}
Box(
modifier = Modifier
.widthIn(max = 230.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
val bmp = image
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = "Photo",
contentScale = ContentScale.Fit,
modifier = Modifier.widthIn(max = 230.dp)
)
} 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)
)
}
}
}
}
@Composable
private fun ChatAvatar(url: String?, visible: Boolean) {
val size = 28.dp
if (!visible) {
Spacer(modifier = Modifier.size(size))
return
}
if (!url.isNullOrBlank()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).clip(CircleShape)
)
} else {
Box(
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)
)
}
}
}
/** Composer with text + gallery (images-only) + camera. */
@Composable
fun ChatComposer(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onSendImage: (ByteArray) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
fun readAndSend(uri: Uri) {
scope.launch {
val bytes = withContext(Dispatchers.IO) {
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
}
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
}
}
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> uri?.let { readAndSend(it) } }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } }
fun launchCamera() {
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
pendingCameraUri = uri
cameraLauncher.launch(uri)
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted: Boolean -> if (granted) launchCamera() }
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)
) {
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)
},
modifier = Modifier.size(42.dp)
) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
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
)
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)
)
}
}
}