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:
parent
c9aa5f1e12
commit
06e4d609f2
|
|
@ -27,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
|
@ -250,7 +252,6 @@ private fun SubmittedAnswerCard(
|
||||||
question: Question,
|
question: Question,
|
||||||
state: LocalQuestionUiState
|
state: LocalQuestionUiState
|
||||||
) {
|
) {
|
||||||
val badge = if (state.isRevealed) "Revealed" else "OK"
|
|
||||||
val label = when {
|
val label = when {
|
||||||
state.isRevealed -> "Answer revealed"
|
state.isRevealed -> "Answer revealed"
|
||||||
!state.partnerHasAnswered -> "Private answer saved — waiting for partner"
|
!state.partnerHasAnswered -> "Private answer saved — waiting for partner"
|
||||||
|
|
@ -274,11 +275,11 @@ private fun SubmittedAnswerCard(
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f)),
|
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = badge,
|
imageVector = if (state.isRevealed) Icons.Filled.Visibility else Icons.Filled.Lock,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
contentDescription = null,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
fontWeight = FontWeight.Bold
|
modifier = Modifier.size(18.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
|
|
||||||
|
|
@ -238,10 +238,13 @@ private fun RevealedPhase(
|
||||||
QuestionDiscussionThread(
|
QuestionDiscussionThread(
|
||||||
messages = state.messages,
|
messages = state.messages,
|
||||||
currentUserId = viewModel.currentUserId,
|
currentUserId = viewModel.currentUserId,
|
||||||
|
partnerPhotoUrl = state.partnerPhotoUrl,
|
||||||
messageInput = state.messageInput,
|
messageInput = state.messageInput,
|
||||||
onMessageInputChanged = viewModel::updateMessageInput,
|
onMessageInputChanged = viewModel::updateMessageInput,
|
||||||
onSendMessage = viewModel::sendMessage,
|
onSendMessage = viewModel::sendMessage,
|
||||||
isRevealed = true
|
isRevealed = true,
|
||||||
|
onSendImage = viewModel::sendImage,
|
||||||
|
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||||
)
|
)
|
||||||
|
|
||||||
// Navigation out of the thread
|
// Navigation out of the thread
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ data class QuestionThreadUiState(
|
||||||
val phase: QuestionPhase = QuestionPhase.INPUT,
|
val phase: QuestionPhase = QuestionPhase.INPUT,
|
||||||
val myAnswer: QuestionAnswer? = null,
|
val myAnswer: QuestionAnswer? = null,
|
||||||
val partnerAnswer: QuestionAnswer? = null,
|
val partnerAnswer: QuestionAnswer? = null,
|
||||||
|
val partnerPhotoUrl: String? = null,
|
||||||
val messages: List<QuestionMessage> = emptyList(),
|
val messages: List<QuestionMessage> = emptyList(),
|
||||||
val reactions: List<QuestionReaction> = emptyList(),
|
val reactions: List<QuestionReaction> = emptyList(),
|
||||||
val pendingWrittenText: String = "",
|
val pendingWrittenText: String = "",
|
||||||
|
|
@ -49,6 +50,9 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
private val questionDao: QuestionDao,
|
private val questionDao: QuestionDao,
|
||||||
private val sealedRevealManager: SealedRevealManager,
|
private val sealedRevealManager: SealedRevealManager,
|
||||||
private val activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor,
|
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
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -59,6 +63,10 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
// Released-once guard for our thread reveal key.
|
// Released-once guard for our thread reveal key.
|
||||||
private var threadKeyReleased = false
|
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(
|
private val _uiState = MutableStateFlow(
|
||||||
QuestionThreadUiState(
|
QuestionThreadUiState(
|
||||||
previousQuestionId = savedStateHandle["prevId"],
|
previousQuestionId = savedStateHandle["prevId"],
|
||||||
|
|
@ -90,9 +98,26 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(question = question, isLoading = false) }
|
_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)
|
val threadId = repository.findOrCreateThreadId(coupleId, questionId, question.category, currentUserId)
|
||||||
_uiState.update { it.copy(threadId = threadId) }
|
_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 {
|
launch {
|
||||||
repository.observeAnswers(coupleId, threadId).collect { answers ->
|
repository.observeAnswers(coupleId, threadId).collect { answers ->
|
||||||
handleAnswers(threadId, answers)
|
handleAnswers(threadId, answers)
|
||||||
|
|
@ -124,16 +149,9 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
val mySealed = answers.find { it.userId == currentUserId }
|
val mySealed = answers.find { it.userId == currentUserId }
|
||||||
val partnerSealed = answers.find { it.userId != currentUserId }
|
val partnerSealed = answers.find { it.userId != currentUserId }
|
||||||
when {
|
when {
|
||||||
mySealed == null ->
|
// Both answered IN this thread (e.g. a question pack answered here) — native reveal.
|
||||||
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
|
mySealed != null && partnerSealed != null -> {
|
||||||
|
// Release our key so the partner can decrypt us, then decrypt theirs.
|
||||||
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.
|
|
||||||
releaseThreadKeyOnce(threadId, partnerSealed.userId)
|
releaseThreadKeyOnce(threadId, partnerSealed.userId)
|
||||||
val mine = decryptOwn(threadId, mySealed)
|
val mine = decryptOwn(threadId, mySealed)
|
||||||
val partner = decryptPartner(threadId, partnerSealed)
|
val partner = decryptPartner(threadId, partnerSealed)
|
||||||
|
|
@ -144,6 +162,19 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) }
|
_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 ───────────────────────────────────────────────────────────────
|
// ─── Reactions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun addReaction(targetUserId: String, emoji: String) {
|
fun addReaction(targetUserId: String, emoji: String) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
package app.closer.ui.questions.components
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -21,12 +37,30 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import app.closer.domain.model.QuestionMessage
|
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
|
@Composable
|
||||||
fun QuestionDiscussionThread(
|
fun QuestionDiscussionThread(
|
||||||
|
|
@ -36,7 +70,10 @@ fun QuestionDiscussionThread(
|
||||||
onMessageInputChanged: (String) -> Unit,
|
onMessageInputChanged: (String) -> Unit,
|
||||||
onSendMessage: () -> Unit,
|
onSendMessage: () -> Unit,
|
||||||
isRevealed: Boolean,
|
isRevealed: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
partnerPhotoUrl: String? = null,
|
||||||
|
onSendImage: (ByteArray) -> Unit = {},
|
||||||
|
loadDecryptedMedia: suspend (String) -> ByteArray? = { null }
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
|
|
@ -71,10 +108,18 @@ fun QuestionDiscussionThread(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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(
|
DiscussionMessageBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isCurrentUser = message.userId == currentUserId
|
isCurrentUser = isMe,
|
||||||
|
partnerAvatarUrl = partnerPhotoUrl,
|
||||||
|
showAvatar = showAvatar,
|
||||||
|
loadDecryptedMedia = loadDecryptedMedia
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +129,8 @@ fun QuestionDiscussionThread(
|
||||||
DiscussionInputBar(
|
DiscussionInputBar(
|
||||||
value = messageInput,
|
value = messageInput,
|
||||||
onValueChange = onMessageInputChanged,
|
onValueChange = onMessageInputChanged,
|
||||||
onSend = onSendMessage
|
onSend = onSendMessage,
|
||||||
|
onSendImage = onSendImage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +138,10 @@ fun QuestionDiscussionThread(
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscussionMessageBubble(
|
private fun DiscussionMessageBubble(
|
||||||
message: QuestionMessage,
|
message: QuestionMessage,
|
||||||
isCurrentUser: Boolean
|
isCurrentUser: Boolean,
|
||||||
|
partnerAvatarUrl: String?,
|
||||||
|
showAvatar: Boolean,
|
||||||
|
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||||
) {
|
) {
|
||||||
val bubbleShape = if (isCurrentUser) {
|
val bubbleShape = if (isCurrentUser) {
|
||||||
RoundedCornerShape(topStart = 14.dp, topEnd = 4.dp, bottomStart = 14.dp, bottomEnd = 14.dp)
|
RoundedCornerShape(topStart = 14.dp, topEnd = 4.dp, bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||||
|
|
@ -102,15 +151,30 @@ private fun DiscussionMessageBubble(
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start
|
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
|
// 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(
|
Surface(
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
color = if (isCurrentUser)
|
color = if (isCurrentUser)
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.widthIn(max = 260.dp)
|
modifier = Modifier.widthIn(max = 240.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = message.text,
|
text = message.text,
|
||||||
|
|
@ -126,18 +190,167 @@ private fun DiscussionMessageBubble(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscussionInputBar(
|
private fun DiscussionInputBar(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<files-path name="profile_photos" path="photos/" />
|
<files-path name="profile_photos" path="photos/" />
|
||||||
|
<cache-path name="chat_captures" path="." />
|
||||||
</paths>
|
</paths>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue