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