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:
parent
db5b8a5f8a
commit
33baf220e4
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue