diff --git a/feature/homeImpl/build.gradle.kts b/feature/homeImpl/build.gradle.kts index 37148a3..4314ace 100644 --- a/feature/homeImpl/build.gradle.kts +++ b/feature/homeImpl/build.gradle.kts @@ -11,4 +11,7 @@ dependencies { implementation(projects.feature.featureAApi) implementation(libs.bundles.exoplayer) + implementation(libs.bundles.camerax) + + implementation(libs.glide.compose) } diff --git a/feature/homeImpl/src/main/AndroidManifest.xml b/feature/homeImpl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f9618a5 --- /dev/null +++ b/feature/homeImpl/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt index a966086..62b9464 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt @@ -1,10 +1,15 @@ package com.featuremodule.homeImpl +import android.graphics.Bitmap +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.featuremodule.core.navigation.HIDE_NAV_BAR import com.featuremodule.homeApi.HomeDestination +import com.featuremodule.homeImpl.camera.TakePhotoScreen import com.featuremodule.homeImpl.exoplayer.ExoplayerScreen +import com.featuremodule.homeImpl.imageUpload.ImageUploadScreen import com.featuremodule.homeImpl.ui.HomeScreen fun NavGraphBuilder.registerHome() { @@ -15,12 +20,36 @@ fun NavGraphBuilder.registerHome() { composable(InternalRoutes.ExoplayerDestination.ROUTE) { ExoplayerScreen() } + + composable(InternalRoutes.ImageUploadDestination.ROUTE) { backStack -> + val bitmap by backStack.savedStateHandle + .getStateFlow(InternalRoutes.ImageUploadDestination.BITMAP_POP_ARG, null) + .collectAsStateWithLifecycle() + ImageUploadScreen(returnedBitmap = bitmap) + } + + composable(InternalRoutes.TakePhotoDestination.ROUTE) { + TakePhotoScreen() + } } -internal sealed class InternalRoutes { +internal class InternalRoutes { object ExoplayerDestination { const val ROUTE = HIDE_NAV_BAR + "exoplayer" fun constructRoute() = ROUTE } + + object ImageUploadDestination { + const val ROUTE = "image_upload" + const val BITMAP_POP_ARG = "bitmap" + + fun constructRoute() = ROUTE + } + + object TakePhotoDestination { + const val ROUTE = HIDE_NAV_BAR + "take_photo" + + fun constructRoute() = ROUTE + } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoContract.kt new file mode 100644 index 0000000..680b5da --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoContract.kt @@ -0,0 +1,11 @@ +package com.featuremodule.homeImpl.camera + +import android.graphics.Bitmap +import com.featuremodule.core.ui.UiEvent +import com.featuremodule.core.ui.UiState + +internal class State : UiState + +internal sealed interface Event : UiEvent { + data class CaptureSuccess(val bitmap: Bitmap, val rotation: Int) : Event +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoScreen.kt new file mode 100644 index 0000000..d9d81f4 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoScreen.kt @@ -0,0 +1,100 @@ +package com.featuremodule.homeImpl.camera + +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture.OnImageCapturedCallback +import androidx.camera.core.ImageProxy +import androidx.camera.view.CameraController +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel + +@Composable +internal fun TakePhotoScreen(viewModel: TakePhotoVM = hiltViewModel()) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + } + val cameraController = remember { + LifecycleCameraController(context).apply { + setEnabledUseCases(CameraController.IMAGE_CAPTURE) + bindToLifecycle(lifecycleOwner) + cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + } + } + + LaunchedEffect(previewView, cameraController) { + previewView.controller = cameraController + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + AndroidView( + factory = { previewView }, + Modifier + .align(Alignment.Center) + .aspectRatio(1f) + .fillMaxSize(), + ) + + IconButton( + onClick = { + runCatching { + cameraController.takePicture( + ContextCompat.getMainExecutor(context), + object : OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + viewModel.postEvent( + Event.CaptureSuccess( + image.toBitmap(), + image.imageInfo.rotationDegrees, + ), + ) + image.close() + } + }, + ) + } + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .size(50.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoVM.kt new file mode 100644 index 0000000..7cfe96c --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/camera/TakePhotoVM.kt @@ -0,0 +1,46 @@ +package com.featuremodule.homeImpl.camera + +import android.graphics.Bitmap +import android.graphics.Matrix +import com.featuremodule.core.navigation.NavCommand +import com.featuremodule.core.navigation.NavManager +import com.featuremodule.core.ui.BaseVM +import com.featuremodule.homeImpl.InternalRoutes.ImageUploadDestination +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class TakePhotoVM @Inject constructor( + private val navManager: NavManager, +) : BaseVM() { + override fun initialState() = State() + + override fun handleEvent(event: Event) { + when (event) { + is Event.CaptureSuccess -> launch { + val rotatedBitmap = rotateBitmap(event.bitmap, event.rotation) + navManager.navigate( + NavCommand.PopBackWithArguments( + mapOf(ImageUploadDestination.BITMAP_POP_ARG to rotatedBitmap), + ), + ) + } + } + } + + // Because image is not rotated by default, it only has rotation value in EXIF + private fun rotateBitmap(bitmap: Bitmap, rotation: Int): Bitmap { + val matrix = Matrix().apply { + postRotate(rotation.toFloat()) + } + return Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true, + ) + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadContract.kt new file mode 100644 index 0000000..2d53add --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadContract.kt @@ -0,0 +1,16 @@ +package com.featuremodule.homeImpl.imageUpload + +import android.graphics.Bitmap +import android.net.Uri +import com.featuremodule.core.ui.UiEvent +import com.featuremodule.core.ui.UiState + +internal data class State( + val image: Any? = null, +) : UiState + +internal sealed interface Event : UiEvent { + data class PhotoTaken(val bitmap: Bitmap) : Event + data class ImagePicked(val uri: Uri) : Event + data object OpenInAppCamera : Event +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadScreen.kt new file mode 100644 index 0000000..93eb93c --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadScreen.kt @@ -0,0 +1,230 @@ +package com.featuremodule.homeImpl.imageUpload + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.pm.PackageManager +import android.graphics.Bitmap +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.activity.result.contract.ActivityResultContracts.TakePicturePreview +import androidx.activity.result.launch +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage + +@Suppress("LongMethod") +@Composable +internal fun ImageUploadScreen( + returnedBitmap: Bitmap?, + viewModel: ImageUploadVM = hiltViewModel(), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsStateWithLifecycle() + var permissionNotGrantedVisibility by remember { mutableStateOf(false) } + var cameraNotFoundVisibility by remember { mutableStateOf(false) } + + val launchSystemCamera = rememberLauncherForActivityResult(TakePicturePreview()) { bitmap -> + bitmap?.let { viewModel.postEvent(Event.PhotoTaken(it)) } + } + val launchSystemCameraPermissionRequest = + rememberLauncherForActivityResult(RequestPermission()) { isGranted -> + if (isGranted) { + try { + launchSystemCamera.launch() + } catch (_: ActivityNotFoundException) { + cameraNotFoundVisibility = true + } + } else { + permissionNotGrantedVisibility = true + } + } + val launchImagePicker = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + uri?.let { viewModel.postEvent(Event.ImagePicked(it)) } + } + val launchInAppCameraPermissionRequest = + rememberLauncherForActivityResult(RequestPermission()) { isGranted -> + if (isGranted) { + viewModel.postEvent(Event.OpenInAppCamera) + } else { + permissionNotGrantedVisibility = true + } + } + + LaunchedEffect(returnedBitmap) { + returnedBitmap?.let { + viewModel.postEvent(Event.PhotoTaken(it)) + } + } + + Box { + ImageUploadScreen( + state = state, + launchSystemCamera = { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_DENIED + ) { + launchSystemCameraPermissionRequest.launch(Manifest.permission.CAMERA) + } else { + try { + launchSystemCamera.launch() + } catch (_: ActivityNotFoundException) { + cameraNotFoundVisibility = true + } + } + }, + launchImagePicker = { + launchImagePicker.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + launchCamera = { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_DENIED + ) { + launchInAppCameraPermissionRequest.launch(Manifest.permission.CAMERA) + } else { + viewModel.postEvent(Event.OpenInAppCamera) + } + }, + ) + + PermissionNotGrantedDialog( + isVisible = permissionNotGrantedVisibility, + onDismiss = { permissionNotGrantedVisibility = false }, + ) + + CameraNotFoundDialog( + isVisible = cameraNotFoundVisibility, + onDismiss = { cameraNotFoundVisibility = false }, + ) + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun ImageUploadScreen( + state: State, + launchSystemCamera: () -> Unit, + launchImagePicker: () -> Unit, + launchCamera: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll( + rememberScrollState(), + ), + contentAlignment = Alignment.Center, + ) { + Column(modifier = Modifier.width(200.dp)) { + GlideImage( + model = state.image, + contentDescription = null, + modifier = Modifier + .padding(vertical = 16.dp) + .size(200.dp), + contentScale = ContentScale.Crop, + ) + + @Composable + fun GenericButton(text: String, onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + ) { + Text(text = text) + } + } + + GenericButton(text = "Open camera app") { launchSystemCamera() } + GenericButton(text = "Open image picker") { launchImagePicker() } + GenericButton(text = "Open in-app camera") { launchCamera() } + } + } +} + +@Composable +private fun PermissionNotGrantedDialog(isVisible: Boolean, onDismiss: () -> Unit) { + if (!isVisible) return + + Dialog(onDismissRequest = onDismiss) { + Card { + Column( + modifier = Modifier + .padding(16.dp) + .width(IntrinsicSize.Max), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Permission was not granted", + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text(text = "Close") + } + } + } + } +} + +@Composable +private fun CameraNotFoundDialog(isVisible: Boolean, onDismiss: () -> Unit) { + if (!isVisible) return + + Dialog(onDismissRequest = onDismiss) { + Card { + Column( + modifier = Modifier + .padding(16.dp) + .width(IntrinsicSize.Max), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Camera app was not found", + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text(text = "Close") + } + } + } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadVM.kt new file mode 100644 index 0000000..8f1fea8 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/imageUpload/ImageUploadVM.kt @@ -0,0 +1,27 @@ +package com.featuremodule.homeImpl.imageUpload + +import com.featuremodule.core.navigation.NavCommand +import com.featuremodule.core.navigation.NavManager +import com.featuremodule.core.ui.BaseVM +import com.featuremodule.homeImpl.InternalRoutes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class ImageUploadVM @Inject constructor( + private val navManager: NavManager, +) : BaseVM() { + override fun initialState() = State() + + override fun handleEvent(event: Event) { + when (event) { + is Event.PhotoTaken -> setState { copy(image = event.bitmap) } + is Event.ImagePicked -> setState { copy(image = event.uri) } + Event.OpenInAppCamera -> launch { + navManager.navigate( + NavCommand.Forward(InternalRoutes.TakePhotoDestination.constructRoute()), + ) + } + } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt index e5bc0c6..3892fac 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt @@ -8,4 +8,5 @@ internal data object State : UiState internal sealed interface Event : UiEvent { data object NavigateToFeatureA : Event data object NavigateToExoplayer : Event + data object NavigateToCamera : Event } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt index 2b7a3e5..ec64729 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt @@ -1,11 +1,10 @@ package com.featuremodule.homeImpl.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Text @@ -25,21 +24,21 @@ internal fun HomeScreen(route: String?, viewModel: HomeVM = hiltViewModel()) { Column( modifier = Modifier.width(IntrinsicSize.Max), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { viewModel.postEvent(Event.NavigateToFeatureA) }, - ) { - Text(text = "Pass number") + @Composable + fun GenericButton(text: String, onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + ) { + Text(text = text) + } } - Spacer(modifier = Modifier.height(24.dp)) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { viewModel.postEvent(Event.NavigateToExoplayer) }, - ) { - Text(text = "Exoplayer") - } + GenericButton(text = "Pass number") { viewModel.postEvent(Event.NavigateToFeatureA) } + GenericButton(text = "Exoplayer") { viewModel.postEvent(Event.NavigateToExoplayer) } + GenericButton(text = "Camera") { viewModel.postEvent(Event.NavigateToCamera) } } } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt index e274dd8..afe3d54 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt @@ -33,6 +33,12 @@ internal class HomeVM @Inject constructor( NavCommand.Forward(InternalRoutes.ExoplayerDestination.constructRoute()), ) } + + Event.NavigateToCamera -> launch { + navManager.navigate( + NavCommand.Forward(InternalRoutes.ImageUploadDestination.constructRoute()), + ) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4eb3191..b240345 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ collections-immutable = "0.3.7" glide-compose = "1.0.0-beta01" leakcanary = "2.14" media3 = "1.4.1" +camerax = "1.4.0" # Versions used for android{} setup sdk-compile = "34" @@ -72,6 +73,11 @@ glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "g media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +camerax-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit-androidx" } @@ -88,6 +94,7 @@ compose = ["androidx-lifecycle-runtime-compose", "compose-ui", "compose-ui-graph "compose-ui-tooling-preview", "compose-material3", "compose-runtime"] network = ["retrofit", "retrofit-converter-moshi", "moshi"] exoplayer = ["media3-exoplayer", "media3-ui"] +camerax = ["camerax-core", "camerax-camera2", "camerax-view", "camerax-lifecycle"] [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }