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.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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue