diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e660932..b8fa854 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.agp) alias(libs.plugins.kotlin) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.composeCompiler) } @@ -43,17 +44,23 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.navigation) + implementation(libs.accompanistPermissions) + implementation(libs.kotlinx.serialization.core) + implementation(libs.camerax) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.lifecycle) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cc9e2f8..2c393e1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + android:enableOnBackInvokedCallback="true" + tools:targetApi="tiramisu"> + if (granted) { + navController.navigate(CameraOverlay) + } else { + isDialogShown.value = true } } + ) + + Box(modifier.fillMaxSize()) { + if (isDialogShown.value) { + CameraPermissionDialog( + permissionState = cameraPermissionState, + onDismiss = { isDialogShown.value = false }, + launchRequest = cameraPermissionState::launchPermissionRequest, + ) + } + } + + NavHost( + navController = navController, + startDestination = Home, + modifier = modifier, + ) { + composable { + Home(takePicture = cameraPermissionState::launchPermissionRequest) + } + composable { + CameraOverlay() + } } } diff --git a/android/app/src/main/java/com/imashnake/aigo/ui/components/Button.kt b/android/app/src/main/java/com/imashnake/aigo/ui/components/Button.kt new file mode 100644 index 0000000..5051d69 --- /dev/null +++ b/android/app/src/main/java/com/imashnake/aigo/ui/components/Button.kt @@ -0,0 +1,41 @@ +package com.imashnake.aigo.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp + +@Composable +fun AigoIconTextButton( + @DrawableRes drawable: Int, + text: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + onClick: () -> Unit = {}, +) { + Button( + onClick = onClick, + modifier = modifier + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.wrapContentSize() + ) { + Icon( + imageVector = ImageVector.vectorResource(drawable), + contentDescription = contentDescription, + ) + Text(text) + } + } +} diff --git a/android/app/src/main/java/com/imashnake/aigo/ui/features/CameraOverlay.kt b/android/app/src/main/java/com/imashnake/aigo/ui/features/CameraOverlay.kt new file mode 100644 index 0000000..6bebf75 --- /dev/null +++ b/android/app/src/main/java/com/imashnake/aigo/ui/features/CameraOverlay.kt @@ -0,0 +1,72 @@ +package com.imashnake.aigo.ui.features + +import android.content.Context +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.imashnake.aigo.R +import kotlinx.serialization.Serializable +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@Composable +fun CameraOverlay(modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize()) { + // TODO: Copied this code from https://medium.com/@deepugeorge2007travel/mastering-camerax-in-jetpack-compose-a-comprehensive-guide-for-android-developers-92ec3591a189 + // Properly set this up using: https://developer.android.com/media/camera/camerax/preview. + val lensFacing = CameraSelector.LENS_FACING_BACK + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val preview = Preview.Builder().build() + val previewView = remember { PreviewView(context) } + val cameraxSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + LaunchedEffect(lensFacing) { + val cameraProvider = context.getCameraProvider() + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(lifecycleOwner, cameraxSelector, preview) + preview.surfaceProvider = previewView.surfaceProvider + } + + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) + Image( + imageVector = ImageVector.vectorResource(R.drawable.blank_go_board), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp) + .graphicsLayer { alpha = 0.3f } + ) + } +} + +private suspend fun Context.getCameraProvider(): ProcessCameraProvider = + suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(this).also { cameraProvider -> + cameraProvider.addListener({ + continuation.resume(cameraProvider.get()) + }, ContextCompat.getMainExecutor(this)) + } + } + +@Serializable +data object CameraOverlay diff --git a/android/app/src/main/java/com/imashnake/aigo/ui/features/CameraPermissionDialog.kt b/android/app/src/main/java/com/imashnake/aigo/ui/features/CameraPermissionDialog.kt new file mode 100644 index 0000000..5ae961a --- /dev/null +++ b/android/app/src/main/java/com/imashnake/aigo/ui/features/CameraPermissionDialog.kt @@ -0,0 +1,56 @@ +package com.imashnake.aigo.ui.features + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.imashnake.aigo.R + +private const val RATIONALE = "The app needs to see the Go board to digitize it. Please grant camera permission." +private const val REQUEST = "Camera permission is required for this feature." + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraPermissionDialog( + permissionState: PermissionState, + onDismiss: () -> Unit, + launchRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + if (permissionState.status.shouldShowRationale) { + TextButton(onClick = { onDismiss(); launchRequest() }) { + Text("Grant") + } + } + }, + modifier = modifier, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.photo_camera), + contentDescription = "Camera Permission", + ) + }, + title = { Text("Camera Permission") }, + text = { + Text( + if (permissionState.status.shouldShowRationale) { + RATIONALE + } else REQUEST + ) + } + ) +} diff --git a/android/app/src/main/java/com/imashnake/aigo/ui/features/Home.kt b/android/app/src/main/java/com/imashnake/aigo/ui/features/Home.kt new file mode 100644 index 0000000..825ecc5 --- /dev/null +++ b/android/app/src/main/java/com/imashnake/aigo/ui/features/Home.kt @@ -0,0 +1,33 @@ +package com.imashnake.aigo.ui.features + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.imashnake.aigo.R +import com.imashnake.aigo.ui.components.AigoIconTextButton +import kotlinx.serialization.Serializable + +@Composable +fun Home( + modifier: Modifier = Modifier, + takePicture: () -> Unit, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AigoIconTextButton( + drawable = R.drawable.photo_camera, + text = "Take Picture", + onClick = takePicture + ) + AigoIconTextButton(R.drawable.round_image, "Pick Image") + } +} + +@Serializable +data object Home diff --git a/android/app/src/main/res/drawable/blank_go_board.xml b/android/app/src/main/res/drawable/blank_go_board.xml new file mode 100644 index 0000000..cac820b --- /dev/null +++ b/android/app/src/main/res/drawable/blank_go_board.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/photo_camera.xml b/android/app/src/main/res/drawable/photo_camera.xml new file mode 100644 index 0000000..36727c6 --- /dev/null +++ b/android/app/src/main/res/drawable/photo_camera.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/round_image.xml b/android/app/src/main/res/drawable/round_image.xml new file mode 100644 index 0000000..c9de175 --- /dev/null +++ b/android/app/src/main/res/drawable/round_image.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 594710e..6a40b4b 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ -