From 06e4d609f270fb554523fb3e8db8e20fb88b5cb9 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 15:20:18 -0500 Subject: [PATCH] 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 --- .../ui/questions/LocalQuestionContent.kt | 13 +- .../ui/questions/QuestionThreadScreen.kt | 5 +- .../ui/questions/QuestionThreadViewModel.kt | 66 ++++- .../components/QuestionDiscussionThread.kt | 261 ++++++++++++++++-- app/src/main/res/xml/file_paths.xml | 1 + 5 files changed, 305 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt index 89de73bf..ba1fad4e 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalQuestionContent.kt @@ -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)) { diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt index f6b341bd..0d8b1c63 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt index 957eb33e..471f4a84 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt @@ -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 = emptyList(), val reactions: List = 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) { diff --git a/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt b/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt index b8aa50d8..1b16b125 100644 --- a/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt +++ b/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt @@ -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(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(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, diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 90464ea2..ee9a19aa 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ +