diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 43090c1b..b1ea3dbd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -190,6 +190,10 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.6.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("androidx.profileinstaller:profileinstaller:1.4.1") + implementation("androidx.camera:camera-core:1.4.2") + implementation("androidx.camera:camera-lifecycle:1.4.2") + implementation("androidx.camera:camera-camera2:1.4.2") + implementation("androidx.camera:camera-view:1.4.2") // Compose BOM val composeBom = platform("androidx.compose:compose-bom:$composeBomVersion") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c8e713f4..0af67a9b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,9 @@ --> + + + diff --git a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt index 33e727de..3f0627ea 100644 --- a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt @@ -1,19 +1,27 @@ package chat.peptide.screens.chat.views.channel +import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.ContentValues +import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri import android.os.Environment import android.provider.MediaStore import android.util.DisplayMetrics import android.util.Log +import android.view.Surface.ROTATION_0 import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade @@ -62,6 +70,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -106,6 +115,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import chat.peptide.R @@ -157,6 +168,7 @@ import kotlinx.coroutines.launch import kotlinx.datetime.Instant import java.io.File import kotlin.math.max +import androidx.camera.core.Preview as CameraPreview sealed class ChannelScreenItem { data class RegularMessage(val message: Message) : ChannelScreenItem() @@ -298,7 +310,6 @@ fun ChannelScreen( } } } - val pickFileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments() ) { uriList -> @@ -308,7 +319,6 @@ fun ChannelScreen( } } } - val pickMediaLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia() ) { @@ -318,70 +328,71 @@ fun ChannelScreen( } } } - val capturedPhotoUri = rememberSaveable { mutableStateOf(null) } - val pickCameraLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.TakePicture() - ) { uriUpdated -> - if (uriUpdated) { - capturedPhotoUri.value?.let { uri -> - processFileUri(uri, null) + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val imageCapture = ImageCapture.Builder().setTargetRotation(ROTATION_0).build() + var showCameraSheet by remember { mutableStateOf(false) } + val capturePhoto = { + scope.launch { + try { + val cameraProvider = cameraProviderFuture.get() + cameraProvider.unbindAll() // Unbind previous use cases + + val name = "peptide-photo-${System.currentTimeMillis()}.jpg" + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + } + val outputOptions = ImageCapture.OutputFileOptions + .Builder( + context.contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) + .build() + + imageCapture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + output.savedUri?.let { uri -> + capturedPhotoUri.value = uri + processFileUri(uri, null) + showCameraSheet = false + } + } + + override fun onError(exc: ImageCaptureException) { + Toast.makeText(context, "Photo capture failed: ${exc.message}", Toast.LENGTH_SHORT).show() + Log.e("ChannelScreen", "Photo capture failed: ", exc) + } + }) + } catch (e: Exception) { + Log.e("ChannelScreen", "Error opening camera: ", e) + Toast.makeText(context, "Error opening camera: ${e.message}", Toast.LENGTH_SHORT).show() } } } - - val openCameraCallback = cb@{ - // Create a new content URI to store the captured image. - val contentResolver = - context.contentResolver - val contentValues = ContentValues().apply { - put( - MediaStore.MediaColumns.DISPLAY_NAME, - "RVL_${System.currentTimeMillis()}.jpg" - ) - put( - MediaStore.MediaColumns.MIME_TYPE, - "image/jpeg" - ) - put( - MediaStore.MediaColumns.RELATIVE_PATH, - Environment.DIRECTORY_PICTURES - ) - } - - try { - capturedPhotoUri.value = - contentResolver.insert( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues - ) - } catch (_: Exception) { - Toast.makeText( - context, - context.getString( - R.string.file_picker_chip_camera_failed - ), - Toast.LENGTH_SHORT - ).show() - - return@cb - } - - try { - capturedPhotoUri.value?.let { uri -> - pickCameraLauncher.launch(uri) - } - } catch (_: Exception) { - Toast.makeText( - context, - context.getString( - R.string.file_picker_chip_camera_none_installed - ), - Toast.LENGTH_SHORT - ).show() + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + capturePhoto() + } else { + Toast.makeText(context, "Camera permission denied", Toast.LENGTH_SHORT).show() + } + } + val openCameraCallback = { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + if (granted) { + showCameraSheet = true + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } } - val openDocumentPickerCallback = { pickFileLauncher.launch(arrayOf("*/*")) } @@ -397,7 +408,7 @@ fun ChannelScreen( // val lazyListState = rememberLazyListState() var disableScroll by remember { mutableStateOf(false) } - + // State to track the highlighted message ID and animation var highlightedMessageId by remember { mutableStateOf(null) } val highlightAlpha by animateFloatAsState( @@ -462,7 +473,10 @@ fun ChannelScreen( } if (messageIndex != -1) { - Log.d("ChannelScreen", "Target message located at index: $messageIndex (reverseLayout=true)") + Log.d( + "ChannelScreen", + "Target message located at index: $messageIndex (reverseLayout=true)" + ) // Instantly jump to the message without long animation try { lazyListState.scrollToItem(index = messageIndex) @@ -493,7 +507,11 @@ fun ChannelScreen( } if (oldestId != null) { Log.d("ChannelScreen", "Pagination: loading older before=$oldestId") - viewModel.loadMessages(amount = 50, before = oldestId, ignoreExisting = true) + viewModel.loadMessages( + amount = 50, + before = oldestId, + ignoreExisting = true + ) } } } @@ -518,7 +536,10 @@ fun ChannelScreen( val beforeSize = viewModel.items.size val anchorIndex = lazyListState.firstVisibleItemIndex val anchorOffset = lazyListState.firstVisibleItemScrollOffset - Log.d("ChannelScreen", "Pagination: loading newer after=$newestId (anchor index=$anchorIndex, offset=$anchorOffset, size=$beforeSize)") + Log.d( + "ChannelScreen", + "Pagination: loading newer after=$newestId (anchor index=$anchorIndex, offset=$anchorOffset, size=$beforeSize)" + ) viewModel.loadMessages(amount = 50, after = newestId, ignoreExisting = true) @@ -531,7 +552,8 @@ fun ChannelScreen( // Shift anchor forward by number of inserted items to keep viewport stable try { lazyListState.scrollToItem(anchorIndex + added, anchorOffset) - } catch (_: Exception) {} + } catch (_: Exception) { + } break } attempts++ @@ -808,12 +830,20 @@ fun ChannelScreen( items = viewModel.items, key = { item -> when (item) { - is ChannelScreenItem.RegularMessage -> item.message.id ?: "regular_null_id" - is ChannelScreenItem.ProspectiveMessage -> item.message.id ?: "prospective_null_id" - is ChannelScreenItem.FailedMessage -> item.message.id ?: "failed_null_id" - is ChannelScreenItem.SystemMessage -> item.message.id ?: "system_null_id" + is ChannelScreenItem.RegularMessage -> item.message.id + ?: "regular_null_id" + + is ChannelScreenItem.ProspectiveMessage -> item.message.id + ?: "prospective_null_id" + + is ChannelScreenItem.FailedMessage -> item.message.id + ?: "failed_null_id" + + is ChannelScreenItem.SystemMessage -> item.message.id + ?: "system_null_id" + is ChannelScreenItem.DateDivider -> "date_${item.instant.toEpochMilliseconds()}" - is ChannelScreenItem.LoadTrigger -> "loadtrigger_${item.after}_${item.before}" + is ChannelScreenItem.LoadTrigger -> "load trigger_${item.after}_${item.before}" is ChannelScreenItem.Loading -> "loading" } }, @@ -832,13 +862,16 @@ fun ChannelScreen( when (item) { is ChannelScreenItem.RegularMessage -> { // Check if this message should be highlighted - val isHighlighted = item.message.id == highlightedMessageId - + val isHighlighted = + item.message.id == highlightedMessageId + // Apply highlight effect if needed Box( modifier = if (isHighlighted) { Modifier.background( - MaterialTheme.colorScheme.primary.copy(alpha = highlightAlpha) + MaterialTheme.colorScheme.primary.copy( + alpha = highlightAlpha + ) ) } else { Modifier @@ -919,13 +952,16 @@ fun ChannelScreen( is ChannelScreenItem.SystemMessage -> { // Check if this message should be highlighted - val isHighlighted = item.message.id == highlightedMessageId - + val isHighlighted = + item.message.id == highlightedMessageId + // Apply highlight effect if needed Box( modifier = if (isHighlighted) { Modifier.background( - MaterialTheme.colorScheme.primary.copy(alpha = highlightAlpha) + MaterialTheme.colorScheme.primary.copy( + alpha = highlightAlpha + ) ) } else { Modifier @@ -1084,6 +1120,49 @@ fun ChannelScreen( } } + // Full-screen camera preview sheet + if (showCameraSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { showCameraSheet = false } + ) { + Box(Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + PreviewView(ctx).also { pv -> + pv.scaleType = PreviewView.ScaleType.FILL_CENTER + // Bind use cases when view is ready + val cameraProvider = cameraProviderFuture.get() + cameraProvider.unbindAll() + val preview = CameraPreview.Builder().build().also { pr -> + pr.surfaceProvider = pv.surfaceProvider + } + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageCapture + ) + } + } + ) + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(24.dp), + onClick = { capturePhoto() } + ) { + Icon( + painter = painterResource(R.drawable.icn_camera_24dp), + contentDescription = stringResource(R.string.file_picker_chip_camera) + ) + } + } + } + } + Column( modifier = Modifier .background(MaterialTheme.colorScheme.surfaceContainer) @@ -1358,7 +1437,7 @@ fun ChannelScreen( ChannelScreenActivePane.None } - MediaPickerGateway( + MediaPickerGateway( onOpenPhotoPicker = { openPhotoPickerCallback() viewModel.activePane =