diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 53d48767..241cf0d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(projects.domain) implementation(projects.feature.login) implementation(projects.feature.main) + implementation(projects.feature.taskCertification) // Firebase implementation(platform(libs.google.firebase.bom)) diff --git a/app/src/main/java/com/yapp/twix/di/FeatureModules.kt b/app/src/main/java/com/yapp/twix/di/FeatureModules.kt index 7365a495..c820c141 100644 --- a/app/src/main/java/com/yapp/twix/di/FeatureModules.kt +++ b/app/src/main/java/com/yapp/twix/di/FeatureModules.kt @@ -3,6 +3,7 @@ package com.yapp.twix.di import com.twix.home.di.homeModule import com.twix.login.di.loginModule import com.twix.main.di.mainModule +import com.twix.task_certification.di.taskCertificationModule import org.koin.core.module.Module val featureModules: List = @@ -10,4 +11,5 @@ val featureModules: List = loginModule, mainModule, homeModule, + taskCertificationModule, ) diff --git a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt index 625bc78e..803c1001 100644 --- a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt +++ b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt @@ -23,4 +23,11 @@ sealed class NavRoutes( object MainGraph : NavRoutes("main_graph") object MainRoute : NavRoutes("main") + + /** + * TaskCertificationGraph + * */ + object TaskCertificationGraph : NavRoutes("task_certification_graph") + + object TaskCertificationRoute : NavRoutes("task_certification") } diff --git a/feature/task-certification/.gitignore b/feature/task-certification/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/task-certification/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/task-certification/build.gradle.kts b/feature/task-certification/build.gradle.kts new file mode 100644 index 00000000..3214a3db --- /dev/null +++ b/feature/task-certification/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.twix.feature) +} + +android { + namespace = "com.twix.task_certification" +} +dependencies { + implementation(libs.bundles.cameraX) + implementation(libs.guava) +} diff --git a/feature/task-certification/consumer-rules.pro b/feature/task-certification/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/task-certification/proguard-rules.pro b/feature/task-certification/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/task-certification/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/task-certification/src/main/AndroidManifest.xml b/feature/task-certification/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3dde0372 --- /dev/null +++ b/feature/task-certification/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt new file mode 100644 index 00000000..3dd4d8d7 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt @@ -0,0 +1,179 @@ +package com.twix.task_certification + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle +import com.twix.task_certification.camera.Camera +import com.twix.task_certification.component.CameraControlBar +import com.twix.task_certification.component.CameraPreviewBox +import com.twix.task_certification.component.TaskCertificationTopBar +import com.twix.task_certification.model.CameraPreview +import com.twix.task_certification.model.TaskCertificationIntent +import com.twix.task_certification.model.TaskCertificationUiState +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +@Composable +fun TaskCertificationRoute( + camera: Camera = koinInject(), + viewModel: TaskCertificationViewModel = koinViewModel(), + navigateToBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val cameraPreview by camera.surfaceRequests.collectAsStateWithLifecycle() + + val lifecycleOwner = LocalLifecycleOwner.current + val coroutineScope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current + + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED, + ) + } + + val permissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + hasPermission = granted + } + + LaunchedEffect(Unit) { + if (!hasPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + DisposableEffect(lifecycleOwner, uiState.lens, hasPermission) { + if (hasPermission) { + coroutineScope.launch { + camera.bind(lifecycleOwner, uiState.lens) + } + } + + onDispose { + camera.unbind() + } + } + + LaunchedEffect(uiState.torch, hasPermission) { + if (hasPermission) { + camera.toggleTorch(uiState.torch) + } + } + + TaskCertificationScreen( + uiState = uiState, + cameraPreview = cameraPreview, + onClickClose = navigateToBack, + onCaptureClick = { + if (!hasPermission) return@TaskCertificationScreen + + coroutineScope.launch { + camera + .takePicture() + .onSuccess { + viewModel.dispatch(TaskCertificationIntent.TakePicture(it)) + } + } + }, + onToggleCameraClick = { + viewModel.dispatch(TaskCertificationIntent.ToggleLens) + }, + onClickFlash = { + viewModel.dispatch(TaskCertificationIntent.ToggleTorch) + }, + ) +} + +@Composable +private fun TaskCertificationScreen( + uiState: TaskCertificationUiState, + cameraPreview: CameraPreview?, + onClickClose: () -> Unit, + onCaptureClick: () -> Unit, + onToggleCameraClick: () -> Unit, + onClickFlash: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .background(GrayColor.C500), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TaskCertificationTopBar( + onClickClose = onClickClose, + ) + + Spacer(modifier = Modifier.height(24.26.dp)) + + AppText( + text = stringResource(R.string.task_certification_title), + style = AppTextStyle.H2, + color = GrayColor.C100, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + CameraPreviewBox( + showTorch = uiState.showTorch, + capture = uiState.capture, + previewRequest = cameraPreview, + torch = uiState.torch, + onClickFlash = { onClickFlash() }, + ) + + Spacer(modifier = Modifier.height(52.dp)) + + CameraControlBar( + onCaptureClick = onCaptureClick, + onToggleCameraClick = onToggleCameraClick, + ) + } +} + +@Preview +@Composable +fun TaskCertificationScreenPreview() { + TwixTheme { + TaskCertificationScreen( + uiState = TaskCertificationUiState(), + cameraPreview = null, + onClickClose = {}, + onCaptureClick = {}, + onToggleCameraClick = {}, + onClickFlash = {}, + ) + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt new file mode 100644 index 00000000..34c3981e --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt @@ -0,0 +1,50 @@ +package com.twix.task_certification + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.twix.task_certification.model.TaskCertificationIntent +import com.twix.task_certification.model.TaskCertificationSideEffect +import com.twix.task_certification.model.TaskCertificationUiState +import com.twix.ui.base.BaseViewModel +import kotlinx.coroutines.launch + +class TaskCertificationViewModel : + BaseViewModel( + TaskCertificationUiState(), + ) { + override suspend fun handleIntent(intent: TaskCertificationIntent) { + when (intent) { + is TaskCertificationIntent.TakePicture -> { + reducePicture(intent.uri) + } + + is TaskCertificationIntent.ToggleLens -> { + reduceLens() + } + + is TaskCertificationIntent.ToggleTorch -> { + reduceTorch() + } + } + } + + private fun reducePicture(uri: Uri?) { + uri?.let { + reduce { updatePicture(uri) } + } ?: run { onFailureCapture() } + } + + private fun onFailureCapture() { + viewModelScope.launch { + emitSideEffect(TaskCertificationSideEffect.ShowImageCaptureFailToast) + } + } + + private fun reduceLens() { + reduce { toggleLens() } + } + + private fun reduceTorch() { + reduce { toggleTorch() } + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/camera/Camera.kt b/feature/task-certification/src/main/java/com/twix/task_certification/camera/Camera.kt new file mode 100644 index 00000000..055975dc --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/camera/Camera.kt @@ -0,0 +1,23 @@ +package com.twix.task_certification.camera + +import android.net.Uri +import androidx.camera.core.CameraSelector +import androidx.lifecycle.LifecycleOwner +import com.twix.task_certification.model.CameraPreview +import com.twix.task_certification.model.TorchStatus +import kotlinx.coroutines.flow.StateFlow + +interface Camera { + val surfaceRequests: StateFlow + + suspend fun bind( + lifecycleOwner: LifecycleOwner, + lens: CameraSelector, + ) + + fun unbind() + + suspend fun takePicture(): Result + + fun toggleTorch(torch: TorchStatus) +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/camera/CaptureCamera.kt b/feature/task-certification/src/main/java/com/twix/task_certification/camera/CaptureCamera.kt new file mode 100644 index 00000000..9ac13295 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/camera/CaptureCamera.kt @@ -0,0 +1,144 @@ +package com.twix.task_certification.camera + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.camera.core.CameraControl +import androidx.camera.core.CameraInfo +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.lifecycle.awaitInstance +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.twix.task_certification.model.CameraPreview +import com.twix.task_certification.model.TorchStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class CaptureCamera( + private val context: Context, +) : Camera { + private var cameraControl: CameraControl? = null + private var cameraInfo: CameraInfo? = null + private var cameraProvider: ProcessCameraProvider? = null + + private val _surfaceRequests = MutableStateFlow(null) + override val surfaceRequests: StateFlow = _surfaceRequests.asStateFlow() + + private val imageCapture: ImageCapture = + ImageCapture + .Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .setFlashMode(ImageCapture.FLASH_MODE_OFF) + .setJpegQuality(JPEG_QUALITY) + .setOutputFormat(ImageCapture.OUTPUT_FORMAT_JPEG) + .build() + + private val preview: Preview = + Preview.Builder().build().apply { + setSurfaceProvider { request -> + _surfaceRequests.value = CameraPreview(request) + } + } + + override suspend fun bind( + lifecycleOwner: LifecycleOwner, + lens: CameraSelector, + ) { + val provider = ProcessCameraProvider.awaitInstance(context) + cameraProvider = provider + + provider.unbindAll() + + val camera = + provider.bindToLifecycle( + lifecycleOwner, + lens, + preview, + imageCapture, + ) + + cameraControl = camera.cameraControl + cameraInfo = camera.cameraInfo + } + + override suspend fun takePicture(): Result = + suspendCancellableCoroutine { continuation -> + val contentValues = contentValues() + val outputOptions = outputFileOptions(contentValues) + + imageCapture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), + capture(continuation), + ) + } + + private fun contentValues(): ContentValues = + ContentValues().apply { + put( + MediaStore.MediaColumns.DISPLAY_NAME, + IMAGE_NAME.format(System.currentTimeMillis()), + ) + put(MediaStore.MediaColumns.MIME_TYPE, IMAGE_MIME_TYPE) + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + put(MediaStore.Images.Media.RELATIVE_PATH, IMAGE_PATH) + } + } + + private fun outputFileOptions(contentValues: ContentValues): ImageCapture.OutputFileOptions = + ImageCapture.OutputFileOptions + .Builder( + context.contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ).build() + + private fun capture(continuation: Continuation>): ImageCapture.OnImageSavedCallback = + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(result: ImageCapture.OutputFileResults) { + val uri = result.savedUri + if (uri != null) { + continuation.resume(Result.success(uri)) + } else { + continuation.resume( + Result.failure(IllegalStateException(URI_NOT_FOUND_EXCEPTION)), + ) + } + } + + override fun onError(exception: ImageCaptureException) { + continuation.resume(Result.failure(exception)) + } + } + + override fun unbind() { + cameraProvider?.unbindAll() + } + + override fun toggleTorch(torch: TorchStatus) { + when (torch) { + TorchStatus.On -> cameraControl?.enableTorch(true) + TorchStatus.Off -> cameraControl?.enableTorch(false) + } + } + + companion object Companion { + private const val JPEG_QUALITY = 95 + + private const val IMAGE_MIME_TYPE = "image/jpeg" + private const val IMAGE_NAME = "task_%d" + private const val IMAGE_PATH = "Pictures/TaskCertification" + + private const val URI_NOT_FOUND_EXCEPTION = "촬영한 이미지의 Uri를 찾을 수 없습니다" + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt new file mode 100644 index 00000000..02e66d83 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt @@ -0,0 +1,73 @@ +package com.twix.task_certification.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.task_certification.R +import com.twix.ui.extension.noRippleClickable + +@Composable +internal fun CameraControlBar( + modifier: Modifier = Modifier, + onCaptureClick: () -> Unit, + onToggleCameraClick: () -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 41.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.btn), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .size(52.dp) + .clip(RoundedCornerShape(3.dp)), + ) + + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_camera_shutter), + contentDescription = null, + modifier = + Modifier + .noRippleClickable(onCaptureClick), + ) + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_camera_toggle), + contentDescription = null, + modifier = + Modifier + .noRippleClickable(onToggleCameraClick), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CameraControlBarPreview() { + CameraControlBar( + onCaptureClick = {}, + onToggleCameraClick = {}, + ) +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt new file mode 100644 index 00000000..79486dc7 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt @@ -0,0 +1,117 @@ +package com.twix.task_certification.component + +import androidx.camera.compose.CameraXViewfinder +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.task_certification.R +import com.twix.task_certification.model.CameraPreview +import com.twix.task_certification.model.CaptureStatus +import com.twix.task_certification.model.TorchStatus +import com.twix.ui.extension.noRippleClickable + +@Composable +fun CameraPreviewBox( + showTorch: Boolean, + capture: CaptureStatus, + previewRequest: CameraPreview?, + torch: TorchStatus, + onClickFlash: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 5.dp) + .aspectRatio(1f) + .border( + color = GrayColor.C400, + width = 2.dp, + shape = RoundedCornerShape(73.83.dp), + ).clip(RoundedCornerShape(73.83.dp)), + ) { + CameraSurface(capture, previewRequest) + + if (showTorch) { + TorchIcon(torch, onClickFlash) + } + } +} + +@Composable +private fun CameraSurface( + capture: CaptureStatus, + previewRequest: CameraPreview?, +) { + when (capture) { + CaptureStatus.NotCaptured -> { + previewRequest?.let { + CameraXViewfinder( + surfaceRequest = it.request, + modifier = Modifier.fillMaxSize(), + ) + } + } + + is CaptureStatus.Captured -> { + AsyncImage( + model = capture.uri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } +} + +@Composable +private fun TorchIcon( + torch: TorchStatus, + onClickFlash: () -> Unit, +) { + val torchIcon = + when (torch) { + TorchStatus.On -> ImageVector.vectorResource(id = R.drawable.ic_camera_torch_on) + TorchStatus.Off -> ImageVector.vectorResource(id = R.drawable.ic_camera_torch_off) + } + + Image( + imageVector = torchIcon, + contentDescription = null, + modifier = + Modifier + .noRippleClickable(onClickFlash) + .padding(start = 30.33.dp, top = 31.82.dp), + ) +} + +@Preview +@Composable +fun CameraPreviewBoxNotCapturedPreview() { + TwixTheme { + CameraPreviewBox( + capture = CaptureStatus.NotCaptured, + showTorch = true, + torch = TorchStatus.Off, + previewRequest = null, + onClickFlash = {}, + ) + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/component/TaskCertificationTopBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/component/TaskCertificationTopBar.kt new file mode 100644 index 00000000..32c917c3 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/component/TaskCertificationTopBar.kt @@ -0,0 +1,46 @@ +package com.twix.task_certification.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.theme.GrayColor +import com.twix.task_certification.R +import com.twix.ui.extension.noRippleClickable + +@Composable +internal fun TaskCertificationTopBar( + onClickClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .background(color = GrayColor.C500), + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_close_c100), + contentDescription = null, + modifier = + Modifier + .padding(24.dp) + .align(Alignment.CenterEnd) + .noRippleClickable(onClickClose), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TaskCertificationTopBarPreview() { + TaskCertificationTopBar({}) +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt b/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt new file mode 100644 index 00000000..15f82a47 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt @@ -0,0 +1,18 @@ +package com.twix.task_certification.di + +import com.twix.navigation.NavRoutes +import com.twix.navigation.base.NavGraphContributor +import com.twix.task_certification.TaskCertificationViewModel +import com.twix.task_certification.camera.Camera +import com.twix.task_certification.camera.CaptureCamera +import com.twix.task_certification.navigation.TaskCertificationGraph +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val taskCertificationModule = + module { + viewModelOf(::TaskCertificationViewModel) + factory { CaptureCamera(get()) } + single(named(NavRoutes.TaskCertificationRoute.route)) { TaskCertificationGraph } + } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/CameraPreview.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/CameraPreview.kt new file mode 100644 index 00000000..898e3ad3 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/CameraPreview.kt @@ -0,0 +1,9 @@ +package com.twix.task_certification.model + +import androidx.camera.core.SurfaceRequest +import androidx.compose.runtime.Immutable + +@Immutable +data class CameraPreview( + val request: SurfaceRequest, +) diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/CaptureStatus.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/CaptureStatus.kt new file mode 100644 index 00000000..45f95d2a --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/CaptureStatus.kt @@ -0,0 +1,13 @@ +package com.twix.task_certification.model + +import android.net.Uri +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface CaptureStatus { + data class Captured( + val uri: Uri, + ) : CaptureStatus + + data object NotCaptured : CaptureStatus +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt new file mode 100644 index 00000000..8aca0940 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt @@ -0,0 +1,14 @@ +package com.twix.task_certification.model + +import android.net.Uri +import com.twix.ui.base.Intent + +sealed interface TaskCertificationIntent : Intent { + data class TakePicture( + val uri: Uri?, + ) : TaskCertificationIntent + + data object ToggleLens : TaskCertificationIntent + + data object ToggleTorch : TaskCertificationIntent +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationSideEffect.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationSideEffect.kt new file mode 100644 index 00000000..28a2de3c --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationSideEffect.kt @@ -0,0 +1,7 @@ +package com.twix.task_certification.model + +import com.twix.ui.base.SideEffect + +sealed interface TaskCertificationSideEffect : SideEffect { + data object ShowImageCaptureFailToast : TaskCertificationSideEffect +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt new file mode 100644 index 00000000..a16f5a18 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt @@ -0,0 +1,41 @@ +package com.twix.task_certification.model + +import android.net.Uri +import androidx.camera.core.CameraSelector +import androidx.compose.runtime.Immutable +import com.twix.ui.base.State + +@Immutable +data class TaskCertificationUiState( + val capture: CaptureStatus = CaptureStatus.NotCaptured, + val torch: TorchStatus = TorchStatus.Off, + val lens: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, + val preview: CameraPreview? = null, +) : State { + val showTorch: Boolean + get() = capture is CaptureStatus.NotCaptured && lens == CameraSelector.DEFAULT_BACK_CAMERA + + fun toggleLens(): TaskCertificationUiState { + val newLens = + if (lens == CameraSelector.DEFAULT_BACK_CAMERA) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + return copy( + lens = newLens, + torch = TorchStatus.Off, + ) + } + + fun toggleTorch(): TaskCertificationUiState { + val newFlashMode = TorchStatus.toggle(torch) + return copy(torch = newFlashMode) + } + + fun updatePicture(uri: Uri): TaskCertificationUiState = + copy( + capture = CaptureStatus.Captured(uri), + torch = TorchStatus.Off, + ) +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/TorchStatus.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/TorchStatus.kt new file mode 100644 index 00000000..622637ff --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/TorchStatus.kt @@ -0,0 +1,15 @@ +package com.twix.task_certification.model + +enum class TorchStatus { + On, + Off, + ; + + companion object { + fun toggle(value: TorchStatus): TorchStatus = + when (value) { + On -> Off + Off -> On + } + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt b/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt new file mode 100644 index 00000000..46b2bfd8 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt @@ -0,0 +1,31 @@ +package com.twix.task_certification.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.twix.navigation.NavRoutes +import com.twix.navigation.base.NavGraphContributor +import com.twix.task_certification.TaskCertificationRoute + +object TaskCertificationGraph : NavGraphContributor { + override val graphRoute: NavRoutes + get() = NavRoutes.TaskCertificationGraph + override val startDestination: String + get() = NavRoutes.TaskCertificationRoute.route + + override fun NavGraphBuilder.registerGraph(navController: NavHostController) { + navigation( + route = graphRoute.route, + startDestination = startDestination, + ) { + composable(NavRoutes.TaskCertificationRoute.route) { + TaskCertificationRoute( + navigateToBack = { + navController.popBackStack() + }, + ) + } + } + } +} diff --git a/feature/task-certification/src/main/res/drawable/btn.png b/feature/task-certification/src/main/res/drawable/btn.png new file mode 100644 index 00000000..0b1fe833 Binary files /dev/null and b/feature/task-certification/src/main/res/drawable/btn.png differ diff --git a/feature/task-certification/src/main/res/drawable/ic_camara_rotate.xml b/feature/task-certification/src/main/res/drawable/ic_camara_rotate.xml new file mode 100644 index 00000000..3d016d50 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_camara_rotate.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_shutter.xml b/feature/task-certification/src/main/res/drawable/ic_camera_shutter.xml new file mode 100644 index 00000000..211c8782 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_camera_shutter.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_toggle.xml b/feature/task-certification/src/main/res/drawable/ic_camera_toggle.xml new file mode 100644 index 00000000..b17b9cf8 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_camera_toggle.xml @@ -0,0 +1,16 @@ + + + + diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_torch_off.xml b/feature/task-certification/src/main/res/drawable/ic_camera_torch_off.xml new file mode 100644 index 00000000..db90d65e --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_camera_torch_off.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_torch_on.xml b/feature/task-certification/src/main/res/drawable/ic_camera_torch_on.xml new file mode 100644 index 00000000..c3949a31 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_camera_torch_on.xml @@ -0,0 +1,14 @@ + + + + diff --git a/feature/task-certification/src/main/res/drawable/ic_close_c100.xml b/feature/task-certification/src/main/res/drawable/ic_close_c100.xml new file mode 100644 index 00000000..949784e6 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_close_c100.xml @@ -0,0 +1,12 @@ + + + diff --git a/feature/task-certification/src/main/res/drawable/image.xml b/feature/task-certification/src/main/res/drawable/image.xml new file mode 100644 index 00000000..fd345544 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/image.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/task-certification/src/main/res/values/strings.xml b/feature/task-certification/src/main/res/values/strings.xml new file mode 100644 index 00000000..88875eed --- /dev/null +++ b/feature/task-certification/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 인증샷 찍기 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fcfaa913..57f800f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,12 @@ compose-ui-test = "1.7.8" # Ktlint ktlint = "14.0.1" +# CameraX +cameraX = "1.5.2" + +# Guava +guava = "33.5.0-android" + # ksp ksp = "2.3.1" @@ -57,11 +63,21 @@ jetbrains-kotlin-jvm = "2.1.0" # Logging kermit = "2.0.8" +junit = "4.13.2" +espressoCore = "3.7.0" +appcompat = "1.7.1" [libraries] # AndroidX androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" } + +# CameraX +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "cameraX" } + android-gradle-plugin = { group = "com.android.tools.build", name = "gradle-api", version.ref = "agp" } compose-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } @@ -126,6 +142,10 @@ kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.re # Logging kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } +junit = { group = "junit", name = "junit", version.ref = "junit" } + +# Guava +guava = { module = "com.google.guava:guava", version.ref = "guava" } [bundles] androidx = [ @@ -133,6 +153,13 @@ androidx = [ "androidx-lifecycle-runtime-ktx", ] +cameraX = [ + "androidx-camera-camera2", + "androidx-camera-lifecycle", + "androidx-camera-view", + "androidx-camera-compose" +] + ktor = [ "ktor-client-okhttp", "ktor-client-content-negotiation", diff --git a/settings.gradle.kts b/settings.gradle.kts index 29626c00..9ba52aea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,3 +36,4 @@ include(":core:design-system") include(":core:network") include(":core:analytics") include(":feature:main") +include(":feature:task-certification")