diff --git a/feature/homeImpl/build.gradle.kts b/feature/homeImpl/build.gradle.kts index 4314ace..55eb848 100644 --- a/feature/homeImpl/build.gradle.kts +++ b/feature/homeImpl/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { implementation(libs.bundles.exoplayer) implementation(libs.bundles.camerax) + implementation(libs.camerax.mlkit) + implementation(libs.gms.mlkit.barcode) implementation(libs.glide.compose) } diff --git a/feature/homeImpl/src/main/AndroidManifest.xml b/feature/homeImpl/src/main/AndroidManifest.xml index f9618a5..15d7e8a 100644 --- a/feature/homeImpl/src/main/AndroidManifest.xml +++ b/feature/homeImpl/src/main/AndroidManifest.xml @@ -4,4 +4,10 @@ android:name="android.hardware.camera" android:required="false" /> + + + + 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 62b9464..d52fb5d 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt @@ -4,9 +4,13 @@ import android.graphics.Bitmap import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.navArgument import com.featuremodule.core.navigation.HIDE_NAV_BAR import com.featuremodule.homeApi.HomeDestination +import com.featuremodule.homeImpl.barcode.BarcodeCameraScreen +import com.featuremodule.homeImpl.barcode.BarcodeResultScreen import com.featuremodule.homeImpl.camera.TakePhotoScreen import com.featuremodule.homeImpl.exoplayer.ExoplayerScreen import com.featuremodule.homeImpl.imageUpload.ImageUploadScreen @@ -31,6 +35,20 @@ fun NavGraphBuilder.registerHome() { composable(InternalRoutes.TakePhotoDestination.ROUTE) { TakePhotoScreen() } + + composable(InternalRoutes.BarcodeCameraDestination.ROUTE) { + BarcodeCameraScreen() + } + + composable( + InternalRoutes.BarcodeResultDestination.ROUTE, + InternalRoutes.BarcodeResultDestination.arguments, + ) { + val barcode = it.arguments + ?.getString(InternalRoutes.BarcodeResultDestination.ARG_BARCODE) + ?: "NONE" + BarcodeResultScreen(barcode) + } } internal class InternalRoutes { @@ -52,4 +70,21 @@ internal class InternalRoutes { fun constructRoute() = ROUTE } + + object BarcodeCameraDestination { + const val ROUTE = HIDE_NAV_BAR + "barcode" + + fun constructRoute() = ROUTE + } + + object BarcodeResultDestination { + const val ARG_BARCODE = "barcode" + const val ROUTE = "barcode_result/{$ARG_BARCODE}" + + val arguments = listOf( + navArgument(ARG_BARCODE) { type = NavType.StringType }, + ) + + fun constructRoute(barcodeValue: String) = "barcode_result/$barcodeValue" + } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeCameraScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeCameraScreen.kt new file mode 100644 index 0000000..794b712 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeCameraScreen.kt @@ -0,0 +1,136 @@ +package com.featuremodule.homeImpl.barcode + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.mlkit.vision.MlKitAnalyzer +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.windowInsetsPadding +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.LifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode + +@Composable +internal fun BarcodeCameraScreen(viewModel: BarcodeVM = hiltViewModel()) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var cameraViewVisibility by remember { mutableStateOf(false) } + val launchCameraPermissionRequest = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + cameraViewVisibility = true + } else { + viewModel.postEvent(Event.PopBack) + } + } + + val cameraController = remember { + createCameraController( + context = context, + lifecycleOwner = lifecycleOwner, + onBarcodeReceived = { viewModel.postEvent(Event.BarcodeReceived(it)) }, + ) + } + + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FIT_CENTER + controller = cameraController + } + } + + LaunchedEffect(context) { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) != PackageManager.PERMISSION_GRANTED + ) { + launchCameraPermissionRequest.launch(Manifest.permission.CAMERA) + } else { + cameraViewVisibility = true + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + if (cameraViewVisibility) { + AndroidView( + factory = { previewView }, + modifier = Modifier + .align(Alignment.Center) + .aspectRatio(1f) + .fillMaxSize(), + ) + } + } +} + +private fun createCameraController( + context: Context, + lifecycleOwner: LifecycleOwner, + onBarcodeReceived: (Barcode) -> Unit, +): LifecycleCameraController { + val barcodeScanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats( + Barcode.FORMAT_QR_CODE, + Barcode.FORMAT_DATA_MATRIX, + Barcode.FORMAT_EAN_13, + Barcode.FORMAT_EAN_8, + ) + .build(), + ) + + return LifecycleCameraController(context).apply { + bindToLifecycle(lifecycleOwner) + setEnabledUseCases(CameraController.IMAGE_ANALYSIS) + cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + setImageAnalysisAnalyzer( + ContextCompat.getMainExecutor(context), + MlKitAnalyzer( + listOf(barcodeScanner), + ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED, + ContextCompat.getMainExecutor(context), + ) { result -> + result?.getValue(barcodeScanner)?.firstOrNull()?.let { + onBarcodeReceived(it) + } + }, + ) + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeContract.kt new file mode 100644 index 0000000..a003cca --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeContract.kt @@ -0,0 +1,12 @@ +package com.featuremodule.homeImpl.barcode + +import com.featuremodule.core.ui.UiEvent +import com.featuremodule.core.ui.UiState +import com.google.mlkit.vision.barcode.common.Barcode + +internal class State : UiState + +internal sealed interface Event : UiEvent { + data object PopBack : Event + data class BarcodeReceived(val barcode: Barcode) : Event +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeResultScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeResultScreen.kt new file mode 100644 index 0000000..c0d32d4 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeResultScreen.kt @@ -0,0 +1,60 @@ +package com.featuremodule.homeImpl.barcode + +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp + +@Composable +internal fun BarcodeResultScreen(barcode: String) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + SelectionContainer { + Text(text = barcode) + } + + Button( + onClick = { + clipboard.setText(AnnotatedString(barcode)) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(context, "Copied $barcode", Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.defaultMinSize(minWidth = 100.dp), + ) { + Text(text = "Copy") + } + + Button( + onClick = { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, barcode) + } + context.startActivity(Intent.createChooser(shareIntent, null)) + }, + modifier = Modifier.defaultMinSize(minWidth = 100.dp), + ) { + Text(text = "Share") + } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeVM.kt new file mode 100644 index 0000000..059290d --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/barcode/BarcodeVM.kt @@ -0,0 +1,42 @@ +package com.featuremodule.homeImpl.barcode + +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 kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +@HiltViewModel +internal class BarcodeVM @Inject constructor( + private val navManager: NavManager, +) : BaseVM() { + override fun initialState() = State() + + // Throttling due to barcode reader sending multiple results before closing camera + private val isBarcodeProcessing = AtomicBoolean(false) + + override fun handleEvent(event: Event) { + when (event) { + Event.PopBack -> launch { navManager.navigate(NavCommand.PopBack) } + + is Event.BarcodeReceived -> launch { + if (isBarcodeProcessing.getAndSet(true)) return@launch + + navManager.navigate( + NavCommand.Forward( + InternalRoutes.BarcodeResultDestination.constructRoute( + event.barcode.displayValue.toString(), + ), + ), + ) + + // Throttle time, can be adjusted as needed + delay(timeMillis = 5000L) + isBarcodeProcessing.set(false) + } + } + } +} 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 3892fac..6cdbe4c 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 @@ -9,4 +9,5 @@ internal sealed interface Event : UiEvent { data object NavigateToFeatureA : Event data object NavigateToExoplayer : Event data object NavigateToCamera : Event + data object NavigateToBarcode : 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 ec64729..e52ca50 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 @@ -39,6 +39,7 @@ internal fun HomeScreen(route: String?, viewModel: HomeVM = hiltViewModel()) { GenericButton(text = "Pass number") { viewModel.postEvent(Event.NavigateToFeatureA) } GenericButton(text = "Exoplayer") { viewModel.postEvent(Event.NavigateToExoplayer) } GenericButton(text = "Camera") { viewModel.postEvent(Event.NavigateToCamera) } + GenericButton(text = "Barcode") { viewModel.postEvent(Event.NavigateToBarcode) } } } } 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 afe3d54..af6c8bc 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 @@ -39,6 +39,12 @@ internal class HomeVM @Inject constructor( NavCommand.Forward(InternalRoutes.ImageUploadDestination.constructRoute()), ) } + + Event.NavigateToBarcode -> launch { + navManager.navigate( + NavCommand.Forward(InternalRoutes.BarcodeCameraDestination.constructRoute()), + ) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b240345..05443fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ glide-compose = "1.0.0-beta01" leakcanary = "2.14" media3 = "1.4.1" camerax = "1.4.0" +gms-mlkit = "18.3.1" # Versions used for android{} setup sdk-compile = "34" @@ -77,6 +78,8 @@ 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" } +camerax-mlkit = { module = "androidx.camera:camera-mlkit-vision", version.ref = "camerax" } +gms-mlkit-barcode = { module = "com.google.android.gms:play-services-mlkit-barcode-scanning", version.ref = "gms-mlkit" } # Testing junit = { module = "junit:junit", version.ref = "junit" }