diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 176dc3db59..002e70b08e 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -302,5 +302,11 @@ dependencies { debugImplementation platform(libs.androidx.compose.bom) debugImplementation libs.androidx.ui.tooling debugImplementation libs.androidx.ui.test.manifest + + implementation libs.androidx.camera.core + implementation libs.androidx.camera.camera2 + implementation libs.androidx.camera.lifecycle + implementation libs.androidx.camera.view + implementation libs.androidx.camera.extensions } diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 8b30f6ee13..dfd0f2df50 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -16,6 +16,9 @@ + + + @@ -121,6 +124,16 @@ + + + + \ No newline at end of file diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index c55c867c91..7fd0e1e627 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -27,6 +27,8 @@ import android.os.Build import android.util.Log import android.util.Size import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.border @@ -56,6 +58,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.LocationOff import androidx.compose.material.icons.filled.LocationOn @@ -118,6 +121,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -197,6 +201,7 @@ fun NewPostView( accountViewModel: AccountViewModel, nav: INav, ) { + val lifecycleOwner = LocalLifecycleOwner.current val postViewModel: NewPostViewModel = viewModel() postViewModel.wantsDirectMessage = enableMessageInterface @@ -206,6 +211,9 @@ fun NewPostView( val scope = rememberCoroutineScope() var showRelaysDialog by remember { mutableStateOf(false) } var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + var showCamera by remember { + mutableStateOf(true) + } LaunchedEffect(key1 = postViewModel.draftTag) { launch(Dispatchers.IO) { @@ -563,7 +571,6 @@ fun NewPostView( @Composable private fun BottomRowActions(postViewModel: NewPostViewModel) { val scrollState = rememberScrollState() - Row( modifier = Modifier @@ -580,6 +587,12 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { postViewModel.selectImage(it) } + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + if (postViewModel.canUsePoll) { // These should be hashtag recommendations the user selects in the future. // val hashtag = stringRes(R.string.poll_hashtag) @@ -625,6 +638,59 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { } } +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun TakePictureButton(onPictureTaken: (Uri) -> Unit) { + var imageUri by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + if (success) { + imageUri?.let { + onPictureTaken(it) + } + } + } + val context = LocalContext.current + val cameraPermissionState = + rememberPermissionState( + Manifest.permission.CAMERA, + onPermissionResult = { + if (it) { + scope.launch(Dispatchers.IO) { + imageUri = getPhotoUri(context) + imageUri?.let { uri -> launcher.launch(uri) } + } + } + }, + ) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { + if (cameraPermissionState.status.isGranted) { + scope.launch(Dispatchers.IO) { + imageUri = getPhotoUri(context) + imageUri?.let { uri -> launcher.launch(uri) } + } + } else { + cameraPermissionState.launchPermissionRequest() + } + }, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } +} + @Composable private fun PollField(postViewModel: NewPostViewModel) { val optionsList = postViewModel.pollOptions diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index 8c8d6b3a80..ff5560063f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.actions +import android.content.Context import android.net.Uri import android.os.Build +import android.os.Environment import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat @@ -52,6 +54,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -60,6 +63,10 @@ import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean @OptIn(ExperimentalPermissionsApi::class) @@ -125,6 +132,23 @@ private fun UploadBoxButton( } } +fun getPhotoUri(context: Context): Uri { + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File + .createTempFile( + "JPEG_${timeStamp}_", + ".jpg", + storageDir, + ).let { + FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + it, + ) + } +} + val DefaultAnimationColors = listOf( Color(0xFF5851D8), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt index d73a275181..60110f49a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt @@ -23,11 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video import android.Manifest import android.net.Uri import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -45,6 +53,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -57,6 +66,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.GallerySelect import com.vitorpamplona.amethyst.ui.actions.NewMediaModel import com.vitorpamplona.amethyst.ui.actions.NewMediaView +import com.vitorpamplona.amethyst.ui.actions.getPhotoUri import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -73,7 +83,15 @@ fun NewImageButton( nav: INav, navScrollToTop: () -> Unit, ) { - var wantsToPost by remember { mutableStateOf(false) } + val context = LocalContext.current + + var isOpen by remember { mutableStateOf(false) } + + var wantsToPostFromGallery by remember { mutableStateOf(false) } + + var wantsToPostFromCamera by remember { mutableStateOf(false) } + + var cameraUri by remember { mutableStateOf(null) } var pickedURI by remember { mutableStateOf(null) } @@ -87,7 +105,46 @@ fun NewImageButton( } } - if (wantsToPost) { + if (wantsToPostFromCamera) { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + if (success) { + cameraUri?.let { + pickedURI = it + } + } + cameraUri = null + wantsToPostFromCamera = false + } + + val cameraPermissionState = + rememberPermissionState( + Manifest.permission.CAMERA, + onPermissionResult = { + if (it) { + scope.launch(Dispatchers.IO) { + cameraUri = getPhotoUri(context) + cameraUri?.let { launcher.launch(it) } + } + } + }, + ) + + if (cameraPermissionState.status.isGranted) { + LaunchedEffect(key1 = accountViewModel) { + launch(Dispatchers.IO) { + cameraUri = getPhotoUri(context) + cameraUri?.let { launcher.launch(it) } + } + } + } else { + LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() } + } + } + + if (wantsToPostFromGallery) { val cameraPermissionState = rememberPermissionState( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -102,7 +159,7 @@ fun NewImageButton( if (showGallerySelect) { GallerySelect( onImageUri = { uri -> - wantsToPost = false + wantsToPostFromGallery = false showGallerySelect = false pickedURI = uri }, @@ -128,18 +185,62 @@ fun NewImageButton( if (postViewModel.isUploadingImage) { ShowProgress(postViewModel) } else { - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) + Column { + if (isOpen) { + FloatingActionButton( + onClick = { + wantsToPostFromCamera = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + wantsToPostFromGallery = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } + + FloatingActionButton( + onClick = { + isOpen = !isOpen + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + contentDescription = stringRes(id = R.string.new_short), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } } } diff --git a/amethyst/src/main/res/xml/file_paths.xml b/amethyst/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000000..a075ef96b4 --- /dev/null +++ b/amethyst/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52e12d2f3f..0ba7b038d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ zoomable = "1.6.2" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.3.0" +androidxCamera = "1.3.4" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } @@ -61,6 +62,11 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmarkJunit4" } androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" } +androidx-camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "androidxCamera" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -136,4 +142,4 @@ googleServices = { id = "com.google.gms.google-services", version.ref = "gms" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' } \ No newline at end of file +serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }