diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c66500..f20d67a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,20 +67,25 @@ android { dependencies { implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") - implementation("androidx.activity:activity-compose:1.6.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") + implementation("androidx.activity:activity-compose:1.7.0") + implementation("androidx.compose.material3:material3:1.1.0-beta01") - implementation("androidx.compose.ui:ui:1.3.3") - implementation("androidx.compose.ui:ui-tooling-preview:1.3.3") - implementation("androidx.compose.material3:material3:1.1.0-alpha06") - implementation("androidx.compose.material:material-icons-extended:1.3.1") + val accompanistVersion = "0.30.0" + implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion") - implementation("androidx.camera:camera-camera2:1.2.1") - implementation("androidx.camera:camera-lifecycle:1.2.1") - implementation("androidx.camera:camera-view:1.2.1") + val composeVersion = "1.4.0" + implementation("androidx.compose.ui:ui:$composeVersion") + implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") + implementation("androidx.compose.material:material-icons-extended:$composeVersion") + debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion") + debugImplementation("androidx.compose.ui:ui-test-manifest:$composeVersion") + + val cameraVersion = "1.2.2" + implementation("androidx.camera:camera-camera2:$cameraVersion") + implementation("androidx.camera:camera-lifecycle:$cameraVersion") + implementation("androidx.camera:camera-view:$cameraVersion") implementation("com.google.zxing:core:3.3.3") - debugImplementation("androidx.compose.ui:ui-tooling:1.3.3") - debugImplementation("androidx.compose.ui:ui-test-manifest:1.3.3") } \ No newline at end of file diff --git a/app/src/main/java/com/henryhiles/qscan/MainActivity.kt b/app/src/main/java/com/henryhiles/qscan/MainActivity.kt index b9a5e1a..69f7a30 100644 --- a/app/src/main/java/com/henryhiles/qscan/MainActivity.kt +++ b/app/src/main/java/com/henryhiles/qscan/MainActivity.kt @@ -1,231 +1,89 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - package com.henryhiles.qscan import android.Manifest import android.app.Activity import android.content.Context -import android.content.pm.PackageManager import android.os.Bundle import android.webkit.URLUtil import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.henryhiles.qscan.components.Camera +import com.henryhiles.qscan.components.alerts.PermissionAlert +import com.henryhiles.qscan.components.alerts.ScannedAlert import com.henryhiles.qscan.ui.theme.QScanTheme +import com.henryhiles.qscan.utils.Helpers.isURL + +const val autoOpenKey = "AUTO_OPEN" class MainActivity : ComponentActivity() { + @OptIn(ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { QScanTheme { - // A surface container using the 'background' color from the theme Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + modifier = Modifier.fillMaxSize() ) { - Screen() - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun Screen() { - var code by remember { mutableStateOf("") } - - val context = LocalContext.current - val lifeCycleOwner = LocalLifecycleOwner.current - val activity = lifeCycleOwner as Activity - val sharedPref = activity.getPreferences(Context.MODE_PRIVATE) - var doNotAsk by remember { - mutableStateOf( - sharedPref.getBoolean( - R.string.should_auto_open_key.toString(), - false - ) - ) - } - var prompted by remember { - mutableStateOf(false) - } - val uriHandler = LocalUriHandler.current - val cameraProviderFuture = remember { - ProcessCameraProvider.getInstance(context) - } - var hasCamPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - ) - } - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { granted -> - hasCamPermission = granted - prompted = true - } - ) - - LaunchedEffect(key1 = true) { - launcher.launch(Manifest.permission.CAMERA) - } - - LaunchedEffect(key1 = code) { - if (doNotAsk && URLUtil.isValidUrl(code)) { - uriHandler.openUri(code) - code = "" - } - } - - LaunchedEffect(key1 = doNotAsk) { - with(sharedPref.edit()) { - putBoolean(R.string.should_auto_open_key.toString(), doNotAsk) - apply() - } - } - - Column(modifier = Modifier.fillMaxSize()) { - if (hasCamPermission) { - AndroidView( - factory = { context -> - val previewView = PreviewView(context) - val preview = androidx.camera.core.Preview.Builder().build() - val selector = - CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - preview.setSurfaceProvider(previewView.surfaceProvider) - val imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) - .build() - imageAnalysis.setAnalyzer( - ContextCompat.getMainExecutor(context), - QrCodeAnalyzer { result -> code = result }) - - try { - cameraProviderFuture.get() - .bindToLifecycle(lifeCycleOwner, selector, preview, imageAnalysis) - } catch (e: Exception) { - e.printStackTrace() - } - previewView - }, modifier = Modifier - .fillMaxSize() - ) - if (code != "" && !doNotAsk) { - val isURL = URLUtil.isValidUrl(code) - - var tempDoNotAsk by remember { mutableStateOf(false) } - - AlertDialog(onDismissRequest = { code = "" }) { - Surface( - shape = MaterialTheme.shapes.large - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "QR Code Scanned", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.align(CenterHorizontally) + var code by remember { mutableStateOf("") } + val context = LocalContext.current + val activity = context as Activity + val sharedPref = activity.getPreferences(Context.MODE_PRIVATE) + var doNotAsk by remember { + mutableStateOf( + sharedPref.getBoolean( + autoOpenKey, + false ) - SelectionContainer { - Text( - text = if (isURL) "This QR code will take you to $code, are you sure you want to go there?" else "The content of that QR Code is \"$code\"", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp) - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - LabelledCheckBox( - checked = tempDoNotAsk, - onCheckedChange = { tempDoNotAsk = it }, - label = "Don't ask again" - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = { code = "" }) { - Text( - text = "Cancel", - style = MaterialTheme.typography.labelLarge - ) - } - if (isURL) - TextButton(onClick = { - uriHandler.openUri(code) - doNotAsk = tempDoNotAsk - code = "" - }) { - Text( - text = "Open URL", - style = MaterialTheme.typography.labelLarge - ) - } - } - } - } - } - } - } else if (prompted) AlertDialog(onDismissRequest = {}) { - Surface( - shape = MaterialTheme.shapes.large - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "No camera permission", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.align(CenterHorizontally) - ) - SelectionContainer { - Text( - text = "Camera permission is needed for this app to function.", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp) ) } + val uriHandler = LocalUriHandler.current + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = { - launcher.launch(Manifest.permission.CAMERA) - }) { - Text( - text = "Grant Permission", - style = MaterialTheme.typography.labelLarge - ) + LaunchedEffect(key1 = code) { + if (doNotAsk && URLUtil.isValidUrl(code)) { + uriHandler.openUri(code) + code = "" + } + } + + LaunchedEffect(key1 = doNotAsk) { + with(sharedPref.edit()) { + putBoolean(autoOpenKey, doNotAsk) + apply() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + if (cameraPermissionState.status.isGranted) { + Camera(onScan = { code = it }) + if (code != "" && !(doNotAsk && isURL(code))) + ScannedAlert( + onDismiss = { code = "" }, + code = code + ) { doNotAsk = it } + } else PermissionAlert( + textToShow = if (cameraPermissionState.status.shouldShowRationale) "The camera is important for this app. Please grant the permission." + else "Camera permission is required for this feature to be available. Please grant the permission.", + permissionName = "camera" + ) { + cameraPermissionState.launchPermissionRequest() } } } } } - } } \ No newline at end of file diff --git a/app/src/main/java/com/henryhiles/qscan/components/Camera.kt b/app/src/main/java/com/henryhiles/qscan/components/Camera.kt new file mode 100644 index 0000000..ac0b915 --- /dev/null +++ b/app/src/main/java/com/henryhiles/qscan/components/Camera.kt @@ -0,0 +1,53 @@ +package com.henryhiles.qscan.components + +import android.content.Context +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.henryhiles.qscan.QrCodeAnalyzer + +@Composable +fun Camera(onScan: (result: String) -> Unit) { + val lifeCycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember { + ProcessCameraProvider.getInstance(lifeCycleOwner as Context) + } + + AndroidView( + factory = { context -> + + val previewView = PreviewView(context) + val preview = Preview.Builder().build() + val selector = + CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + preview.setSurfaceProvider(previewView.surfaceProvider) + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + imageAnalysis.setAnalyzer( + ContextCompat.getMainExecutor(context), + QrCodeAnalyzer(onScan) + ) + + try { + cameraProviderFuture.get() + .bindToLifecycle(lifeCycleOwner, selector, preview, imageAnalysis) + } catch (e: Exception) { + e.printStackTrace() + } + previewView + }, modifier = Modifier + .fillMaxSize() + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/henryhiles/qscan/LabeledCheckbox.kt b/app/src/main/java/com/henryhiles/qscan/components/LabeledCheckbox.kt similarity index 100% rename from app/src/main/java/com/henryhiles/qscan/LabeledCheckbox.kt rename to app/src/main/java/com/henryhiles/qscan/components/LabeledCheckbox.kt diff --git a/app/src/main/java/com/henryhiles/qscan/components/alerts/PermissionAlert.kt b/app/src/main/java/com/henryhiles/qscan/components/alerts/PermissionAlert.kt new file mode 100644 index 0000000..b3edca8 --- /dev/null +++ b/app/src/main/java/com/henryhiles/qscan/components/alerts/PermissionAlert.kt @@ -0,0 +1,50 @@ +package com.henryhiles.qscan.components.alerts + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun PermissionAlert(textToShow: String, permissionName: String, onGrantRequest: () -> Unit) { + AlertDialog( + title = { + Text( + text = "${permissionName.replaceFirstChar { it.uppercaseChar() }} permission required", + ) + }, + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = onGrantRequest) { + Text( + text = "Grant Permission", + ) + } + }, + text = { + SelectionContainer { + Text( + text = textToShow, + ) + } + } + ) +} + + +//Surface( +//shape = MaterialTheme.shapes.large +//) { +// Column(modifier = Modifier.padding(16.dp)) { + + +// +// Row( +// modifier = Modifier.fillMaxWidth(), +// horizontalArrangement = Arrangement.End +// ) { + +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/henryhiles/qscan/components/alerts/ScannedAlert.kt b/app/src/main/java/com/henryhiles/qscan/components/alerts/ScannedAlert.kt new file mode 100644 index 0000000..4939a54 --- /dev/null +++ b/app/src/main/java/com/henryhiles/qscan/components/alerts/ScannedAlert.kt @@ -0,0 +1,65 @@ +package com.henryhiles.qscan.components.alerts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.henryhiles.qscan.LabelledCheckBox +import com.henryhiles.qscan.utils.Helpers.isURL + +@Composable +fun ScannedAlert(onDismiss: () -> Unit, code: String, onChangeDoNotAsk: (Boolean) -> Unit) { + val uriHandler = LocalUriHandler.current + var tempDoNotAsk by remember { mutableStateOf(false) } + + AlertDialog( + title = { + Text( + text = "QR Code Scanned", + ) + }, onDismissRequest = onDismiss, dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = "Cancel", + ) + } + }, + text = { + Column { + SelectionContainer { + Text( + text = if (isURL(code)) "This QR code will take you to $code, are you sure you want to go there?" else "The content of that QR Code is \"$code\"", + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + LabelledCheckBox( + checked = tempDoNotAsk, + onCheckedChange = { tempDoNotAsk = it }, + label = "Don't ask again" + ) + } + } + }, + confirmButton = { + if (isURL(code)) + TextButton(onClick = { + uriHandler.openUri(code) + onChangeDoNotAsk(tempDoNotAsk) + onDismiss() + }) { + Text( + text = "Open URL", + ) + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/henryhiles/qscan/utils/helpers.kt b/app/src/main/java/com/henryhiles/qscan/utils/helpers.kt new file mode 100644 index 0000000..0410714 --- /dev/null +++ b/app/src/main/java/com/henryhiles/qscan/utils/helpers.kt @@ -0,0 +1,9 @@ +package com.henryhiles.qscan.utils + +import android.webkit.URLUtil + +object Helpers { + fun isURL(value: String): Boolean { + return URLUtil.isValidUrl(value) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fb72f6..b956e11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,3 @@ QScan - AUTO_OPEN \ No newline at end of file