feat(chat): image picker (gallery + camera), encrypted image rendering, messenger-style avatars on consecutive bubbles

- QuestionDiscussionThread: gallery picker via PickVisualMedia, camera capture via FileProvider, EncryptedChatImage composable decrypts + renders, MessageAvatar with partner photo
- QuestionThreadViewModel: sendImage, loadDecryptedMedia, dailyRevealed skip for already-revealed daily questions, partner photo loading
- QuestionThreadScreen: pass loadDecryptedMedia to discussion thread
- LocalQuestionContent: pass partnerPhotoUrl to discussion thread
- file_paths.xml: cache-path for camera capture
This commit is contained in:
null 2026-06-24 15:20:18 -05:00
parent c9aa5f1e12
commit 06e4d609f2
5 changed files with 305 additions and 41 deletions

View File

@ -27,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
@ -250,7 +252,6 @@ private fun SubmittedAnswerCard(
question: Question,
state: LocalQuestionUiState
) {
val badge = if (state.isRevealed) "Revealed" else "OK"
val label = when {
state.isRevealed -> "Answer revealed"
!state.partnerHasAnswered -> "Private answer saved — waiting for partner"
@ -274,11 +275,11 @@ private fun SubmittedAnswerCard(
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f)),
contentAlignment = Alignment.Center
) {
Text(
text = badge,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
Icon(
imageVector = if (state.isRevealed) Icons.Filled.Visibility else Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(18.dp)
)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {

View File

@ -238,10 +238,13 @@ private fun RevealedPhase(
QuestionDiscussionThread(
messages = state.messages,
currentUserId = viewModel.currentUserId,
partnerPhotoUrl = state.partnerPhotoUrl,
messageInput = state.messageInput,
onMessageInputChanged = viewModel::updateMessageInput,
onSendMessage = viewModel::sendMessage,
isRevealed = true
isRevealed = true,
onSendImage = viewModel::sendImage,
loadDecryptedMedia = viewModel::loadDecryptedMedia
)
// Navigation out of the thread

View File

@ -31,6 +31,7 @@ data class QuestionThreadUiState(
val phase: QuestionPhase = QuestionPhase.INPUT,
val myAnswer: QuestionAnswer? = null,
val partnerAnswer: QuestionAnswer? = null,
val partnerPhotoUrl: String? = null,
val messages: List<QuestionMessage> = emptyList(),
val reactions: List<QuestionReaction> = emptyList(),
val pendingWrittenText: String = "",
@ -49,6 +50,9 @@ class QuestionThreadViewModel @Inject constructor(
private val questionDao: QuestionDao,
private val sealedRevealManager: SealedRevealManager,
private val activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor,
private val localAnswerRepository: app.closer.domain.repository.LocalAnswerRepository,
private val userRepository: app.closer.domain.repository.UserRepository,
private val coupleRepository: app.closer.domain.repository.CoupleRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -59,6 +63,10 @@ class QuestionThreadViewModel @Inject constructor(
// Released-once guard for our thread reveal key.
private var threadKeyReleased = false
// True when the matching daily question was already answered + revealed in the daily flow, so
// the discussion (chat) should open directly here rather than asking the user to re-answer.
private var dailyRevealed = false
private val _uiState = MutableStateFlow(
QuestionThreadUiState(
previousQuestionId = savedStateHandle["prevId"],
@ -90,9 +98,26 @@ class QuestionThreadViewModel @Inject constructor(
}
_uiState.update { it.copy(question = question, isLoading = false) }
// If this question's daily reveal is already complete, skip the answer phase and
// open the chat directly — the answers were already given/seen in the daily flow.
dailyRevealed = runCatching {
localAnswerRepository.getAnswer(questionId)?.isRevealed == true
}.getOrDefault(false)
val threadId = repository.findOrCreateThreadId(coupleId, questionId, question.category, currentUserId)
_uiState.update { it.copy(threadId = threadId) }
// Load both partners' avatars so each chat message can show its sender's photo,
// like a modern messaging thread.
launch {
val couple = runCatching { coupleRepository.getCoupleForUser(currentUserId) }.getOrNull()
val partnerId = couple?.userIds?.firstOrNull { it != currentUserId }
val partnerPhoto = partnerId?.let {
runCatching { userRepository.getUser(it)?.photoUrl }.getOrNull()
}
_uiState.update { it.copy(partnerPhotoUrl = partnerPhoto) }
}
launch {
repository.observeAnswers(coupleId, threadId).collect { answers ->
handleAnswers(threadId, answers)
@ -124,16 +149,9 @@ class QuestionThreadViewModel @Inject constructor(
val mySealed = answers.find { it.userId == currentUserId }
val partnerSealed = answers.find { it.userId != currentUserId }
when {
mySealed == null ->
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
partnerSealed == null ->
_uiState.update {
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
}
else -> {
// Both answered — release our key so the partner can decrypt us, then decrypt theirs.
// Both answered IN this thread (e.g. a question pack answered here) — native reveal.
mySealed != null && partnerSealed != null -> {
// Release our key so the partner can decrypt us, then decrypt theirs.
releaseThreadKeyOnce(threadId, partnerSealed.userId)
val mine = decryptOwn(threadId, mySealed)
val partner = decryptPartner(threadId, partnerSealed)
@ -144,6 +162,19 @@ class QuestionThreadViewModel @Inject constructor(
_uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) }
}
}
// Daily question already revealed in the daily flow → open the chat directly so the
// couple can message about it (no re-answering needed here).
dailyRevealed ->
_uiState.update { it.copy(phase = QuestionPhase.REVEALED, myAnswer = null, partnerAnswer = null) }
mySealed != null ->
_uiState.update {
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
}
else ->
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
}
}
@ -294,6 +325,21 @@ class QuestionThreadViewModel @Inject constructor(
}
}
fun sendImage(imageBytes: ByteArray) {
val state = _uiState.value
val threadId = state.threadId ?: return
if (state.phase != QuestionPhase.REVEALED) return
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
viewModelScope.launch {
runCatching { repository.sendImageMessage(coupleId, threadId, currentUserId, imageBytes) }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Couldn't send the photo.") } }
}
}
/** Downloads + decrypts an image message's bytes for display (called lazily by the UI). */
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
repository.loadDecryptedMedia(coupleId, mediaUrl)
// ─── Reactions ───────────────────────────────────────────────────────────────
fun addReaction(targetUserId: String, emoji: String) {

View File

@ -1,6 +1,16 @@
package app.closer.ui.questions.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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -8,10 +18,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -21,12 +37,30 @@ 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.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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
@Composable
fun QuestionDiscussionThread(
@ -36,7 +70,10 @@ fun QuestionDiscussionThread(
onMessageInputChanged: (String) -> Unit,
onSendMessage: () -> Unit,
isRevealed: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
partnerPhotoUrl: String? = null,
onSendImage: (ByteArray) -> Unit = {},
loadDecryptedMedia: suspend (String) -> ByteArray? = { null }
) {
Column(modifier = modifier.fillMaxWidth()) {
HorizontalDivider(
@ -71,10 +108,18 @@ fun QuestionDiscussionThread(
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
messages.forEach { message ->
messages.forEachIndexed { index, message ->
val isMe = message.userId == currentUserId
// Show the sender's avatar only on the last message of a consecutive run, like
// modern chat apps — the others reserve the space so bubbles stay aligned.
val showAvatar = index == messages.lastIndex ||
messages[index + 1].userId != message.userId
DiscussionMessageBubble(
message = message,
isCurrentUser = message.userId == currentUserId
isCurrentUser = isMe,
partnerAvatarUrl = partnerPhotoUrl,
showAvatar = showAvatar,
loadDecryptedMedia = loadDecryptedMedia
)
}
}
@ -84,7 +129,8 @@ fun QuestionDiscussionThread(
DiscussionInputBar(
value = messageInput,
onValueChange = onMessageInputChanged,
onSend = onSendMessage
onSend = onSendMessage,
onSendImage = onSendImage
)
}
}
@ -92,7 +138,10 @@ fun QuestionDiscussionThread(
@Composable
private fun DiscussionMessageBubble(
message: QuestionMessage,
isCurrentUser: Boolean
isCurrentUser: Boolean,
partnerAvatarUrl: String?,
showAvatar: Boolean,
loadDecryptedMedia: suspend (String) -> ByteArray?
) {
val bubbleShape = if (isCurrentUser) {
RoundedCornerShape(topStart = 14.dp, topEnd = 4.dp, bottomStart = 14.dp, bottomEnd = 14.dp)
@ -102,26 +151,120 @@ private fun DiscussionMessageBubble(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
Surface(
shape = bubbleShape,
color = if (isCurrentUser)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 260.dp)
) {
Text(
text = message.text,
style = MaterialTheme.typography.bodySmall,
// Messenger style: only the partner's avatar is shown (on the left). Our own messages are
// just bubbles on the right with no avatar.
if (!isCurrentUser) {
MessageAvatar(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.onPrimaryContainer
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
maxLines = 10,
overflow = TextOverflow.Ellipsis
MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 240.dp)
) {
Text(
text = message.text,
style = MaterialTheme.typography.bodySmall,
color = if (isCurrentUser)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
maxLines = 10,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
/** Downloads the encrypted image bytes, decrypts them on-device, and renders the photo. */
@Composable
private fun EncryptedChatImage(
mediaUrl: String,
shape: androidx.compose.ui.graphics.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 = 220.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 = 220.dp)
)
} else {
// Decrypting / downloading — keep a square placeholder with a spinner.
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 MessageAvatar(url: String?, visible: Boolean) {
val size = 28.dp
if (!visible) {
// Reserve the space so consecutive bubbles from the same sender stay aligned.
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)
)
}
}
@ -131,13 +274,83 @@ private fun DiscussionMessageBubble(
private fun DiscussionInputBar(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit
onSend: () -> Unit,
onSendImage: (ByteArray) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Read the picked/captured image bytes off the main thread, then hand them up to be encrypted
// and sent.
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)
}
}
// Gallery — images only (modern Photo Picker).
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> uri?.let { readAndSend(it) } }
// Camera capture into a temp file via FileProvider.
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(8.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
IconButton(
onClick = {
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = 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(40.dp)
) {
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = "Take a photo",
tint = MaterialTheme.colorScheme.primary
)
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="profile_photos" path="photos/" />
<cache-path name="chat_captures" path="." />
</paths>