diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt new file mode 100644 index 00000000..1a9f92f9 --- /dev/null +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt new file mode 100644 index 00000000..0df50319 --- /dev/null +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -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 = 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 = _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 + } +} diff --git a/app/src/main/java/app/closer/ui/messages/MessagesInboxScreen.kt b/app/src/main/java/app/closer/ui/messages/MessagesInboxScreen.kt new file mode 100644 index 00000000..14761ccc --- /dev/null +++ b/app/src/main/java/app/closer/ui/messages/MessagesInboxScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/app/closer/ui/messages/MessagesInboxViewModel.kt b/app/src/main/java/app/closer/ui/messages/MessagesInboxViewModel.kt new file mode 100644 index 00000000..3239e98f --- /dev/null +++ b/app/src/main/java/app/closer/ui/messages/MessagesInboxViewModel.kt @@ -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 = 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 = _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) } + } + } + } +} diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt new file mode 100644 index 00000000..4344d0b4 --- /dev/null +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -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(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(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) + ) + } + } +}