diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt index 23384f6d..e15027cc 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt @@ -26,6 +26,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.utils.MultipleEventsCutter import com.ninecraft.booket.core.common.utils.get @@ -93,6 +94,8 @@ fun ReedButton( Text( text = text, + overflow = TextOverflow.Ellipsis, + maxLines = 1, style = sizeStyle.textStyle.copy( color = if (enabled) colorStyle.contentColor() else colorStyle.disabledContentColor(), ), diff --git a/core/designsystem/src/main/res/drawable/ic_gallery.xml b/core/designsystem/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 00000000..8b079b15 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt index fb39d0fc..5e0c8d66 100644 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt @@ -1,8 +1,10 @@ package com.ninecraft.booket.core.ocr.recognizer +import android.content.Context import android.net.Uri import android.util.Base64 import com.ninecraft.booket.core.common.utils.runSuspendCatching +import com.ninecraft.booket.core.di.ApplicationContext import com.ninecraft.booket.core.ocr.BuildConfig import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest import com.ninecraft.booket.core.ocr.model.CloudVisionRequest @@ -21,13 +23,22 @@ import com.ninecraft.booket.core.di.DataScope @SingleIn(DataScope::class) @Inject class CloudOcrRecognizer( + @ApplicationContext private val context: Context, private val service: CloudVisionService, ) { suspend fun recognizeText(imageUri: Uri): Result = runSuspendCatching { withContext(Dispatchers.IO) { - val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.") - val file = File(filePath) - val byte = file.readBytes() + val byte = when (imageUri.scheme) { + null, "file" -> { + val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.") + val file = File(filePath) + file.readBytes() + } + else -> { + context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() } + ?: throw IllegalArgumentException("Unable to open image input stream.") + } + } val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP) val request = CloudVisionRequest( diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt index ae844622..9a4cd21a 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt @@ -32,6 +32,8 @@ fun ReedDialog( description: String? = null, dismissButtonText: String? = null, onDismissRequest: () -> Unit = {}, + dismissOnClickOutside: Boolean = true, + dismissOnBackPress: Boolean = true, headerContent: @Composable (() -> Unit)? = null, ) { Dialog( @@ -39,6 +41,8 @@ fun ReedDialog( onDismissRequest() }, properties = DialogProperties( + dismissOnClickOutside = dismissOnClickOutside, + dismissOnBackPress = dismissOnBackPress, usePlatformDefaultWidth = false, ), ) { diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt index 8b9e8569..5b4a7317 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt @@ -14,7 +14,7 @@ internal fun HandleOcrSideEffects( RememberedEffect(state.sideEffect) { when (state.sideEffect) { is OcrSideEffect.ShowToast -> { - Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + Toast.makeText(context, state.sideEffect.message.asString(context), Toast.LENGTH_SHORT).show() } null -> {} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt index 1d050281..bd45e099 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt @@ -2,14 +2,21 @@ package com.ninecraft.booket.feature.record.ocr import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.core.net.toUri import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.core.common.utils.UiText import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrSideEffect.ShowToast import com.ninecraft.booket.feature.screens.OcrScreen +import com.ninecraft.booket.feature.screens.OcrScreen.OcrResult import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained @@ -24,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @AssistedInject @@ -41,22 +49,65 @@ class OcrPresenter( companion object { private const val RECORD_OCR_SENTENCE = "record_OCR_sentence" + private const val CAMERA_MAX_FAILURES = 2 } @Composable override fun present(): OcrUiState { val scope = rememberCoroutineScope() + var isLoading by rememberRetained { mutableStateOf(false) } var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) } var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) } + var selectedImage by rememberRetained { mutableStateOf("") } var sentenceList by rememberRetained { mutableStateOf(persistentListOf()) } var selectedIndices by rememberRetained { mutableStateOf(persistentSetOf()) } var mergedSentence by rememberRetained { mutableStateOf("") } var isTextDetectionFailed by rememberRetained { mutableStateOf(false) } + var isCameraRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) } + var isGalleryRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) } var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) } - var isLoading by rememberRetained { mutableStateOf(false) } + var cameraFailureCount by rememberRetained { mutableIntStateOf(0) } var sideEffect by rememberRetained { mutableStateOf(null) } - fun recognizeText(imageUri: Uri) { + LaunchedEffect(isTextDetectionFailed) { + if (isTextDetectionFailed) { + delay(2000) + isTextDetectionFailed = false + } + } + + fun handleRecognitionSuccess(text: String) { + isTextDetectionFailed = false + cameraFailureCount = 0 + + val sentences = text + .split("\n") + .map { it.trim() } + .filter { it.isNotEmpty() } + + sentenceList = sentences.toPersistentList() + currentUi = OcrUi.RESULT + analyticsHelper.logScreenView(RECORD_OCR_SENTENCE) + } + + fun handleRecognitionFailure(source: RecognizeSource) { + when (source) { + RecognizeSource.CAMERA -> { + isTextDetectionFailed = true + cameraFailureCount += 1 + + if (cameraFailureCount > CAMERA_MAX_FAILURES) { + isCameraRecognitionFailedDialogVisible = true + } + } + + RecognizeSource.GALLERY -> { + isGalleryRecognitionFailedDialogVisible = true + } + } + } + + fun recognizeText(imageUri: Uri, source: RecognizeSource) { scope.launch { try { isLoading = true @@ -65,25 +116,17 @@ class OcrPresenter( val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty() if (text.isNotBlank()) { - isTextDetectionFailed = false - val sentences = text - .split("\n") - .map { it.trim() } - .filter { it.isNotEmpty() } - - sentenceList = sentences.toPersistentList() - currentUi = OcrUi.RESULT - analyticsHelper.logScreenView(RECORD_OCR_SENTENCE) + handleRecognitionSuccess(text) } else { - isTextDetectionFailed = true + handleRecognitionFailure(source) } } .onFailure { exception -> - isTextDetectionFailed = true + handleRecognitionFailure(source) val handleErrorMessage = { message: String -> Logger.e("Cloud Vision API Error: ${exception.message}") - sideEffect = OcrSideEffect.ShowToast(message) + sideEffect = ShowToast(UiText.DirectString(message)) } handleException( @@ -118,14 +161,24 @@ class OcrPresenter( is OcrUiEvent.OnCaptureFailed -> { isLoading = false - sideEffect = OcrSideEffect.ShowToast("이미지 캡처에 실패했어요") + sideEffect = ShowToast(UiText.StringResource(R.string.ocr_capture_failed)) Logger.e("ImageCaptureException: ${event.exception.message}") } is OcrUiEvent.OnImageCaptured -> { isTextDetectionFailed = false - recognizeText(event.imageUri) + recognizeText(event.imageUri, RecognizeSource.CAMERA) + } + + is OcrUiEvent.OnImageSelected -> { + currentUi = OcrUi.IMAGE + selectedImage = event.imageUri + isTextDetectionFailed = false + cameraFailureCount = 0 + + val pareUri = selectedImage.toUri() + recognizeText(pareUri, RecognizeSource.GALLERY) } is OcrUiEvent.OnReCaptureButtonClick -> { @@ -135,7 +188,7 @@ class OcrPresenter( is OcrUiEvent.OnSelectionConfirmed -> { mergedSentence = selectedIndices .sorted().joinToString("") { sentenceList[it] } - navigator.pop(result = OcrScreen.OcrResult(mergedSentence)) + navigator.pop(result = OcrResult(mergedSentence)) } is OcrUiEvent.OnSentenceSelected -> { @@ -155,6 +208,19 @@ class OcrPresenter( is OcrUiEvent.OnRecaptureDialogDismissed -> { isRecaptureDialogVisible = false } + + OcrUiEvent.OnImageContentClosed -> { + currentUi = OcrUi.CAMERA + } + + OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> { + isCameraRecognitionFailedDialogVisible = false + cameraFailureCount = 0 + } + + OcrUiEvent.OnImageRecognitionFailedDialogDismissed -> { + isGalleryRecognitionFailedDialogVisible = false + } } } @@ -165,9 +231,12 @@ class OcrPresenter( return OcrUiState( currentUi = currentUi, isPermissionDialogVisible = isPermissionDialogVisible, + selectedImage = selectedImage, sentenceList = sentenceList, selectedIndices = selectedIndices, isTextDetectionFailed = isTextDetectionFailed, + isCameraRecognitionFailedDialogVisible = isCameraRecognitionFailedDialogVisible, + isGalleryRecognitionFailedDialogVisible = isGalleryRecognitionFailedDialogVisible, isRecaptureDialogVisible = isRecaptureDialogVisible, isLoading = isLoading, sideEffect = sideEffect, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt index f49756b2..45c52061 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt @@ -1,80 +1,14 @@ package com.ninecraft.booket.feature.record.ocr -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.provider.Settings -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.view.LifecycleCameraController -import androidx.camera.view.PreviewView -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -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.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -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.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.ninecraft.booket.core.designsystem.ComponentPreview -import com.ninecraft.booket.core.designsystem.component.button.ReedButton -import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle -import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle -import com.ninecraft.booket.core.designsystem.theme.Neutral950 -import com.ninecraft.booket.core.designsystem.theme.ReedTheme -import com.ninecraft.booket.core.designsystem.theme.White -import com.ninecraft.booket.core.ui.ReedScaffold -import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar -import com.ninecraft.booket.core.ui.component.ReedDialog -import com.ninecraft.booket.feature.record.R -import com.ninecraft.booket.feature.record.ocr.component.CameraFrame -import com.ninecraft.booket.feature.record.ocr.component.SentenceBox +import com.ninecraft.booket.feature.record.ocr.content.OcrCameraContent +import com.ninecraft.booket.feature.record.ocr.content.OcrImageContent +import com.ninecraft.booket.feature.record.ocr.content.OcrResultContent import com.ninecraft.booket.feature.screens.OcrScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject import dev.zacsweers.metro.AppScope -import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController -import java.io.File -import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition @CircuitInject(OcrScreen::class, AppScope::class) @@ -86,350 +20,8 @@ internal fun OcrUi( HandleOcrSideEffects(state = state) when (state.currentUi) { - OcrUi.CAMERA -> CameraPreview(state = state, modifier = modifier) - OcrUi.RESULT -> TextScanResult(state = state, modifier = modifier) - } -} - -@TraceRecomposition -@Composable -private fun CameraPreview( - state: OcrUiState, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val permission = android.Manifest.permission.CAMERA - - /** - * Camera Permission Request - */ - val isGranted by produceState( - initialValue = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, - key1 = lifecycleOwner, // lifecycle 변경 시 재설정 - ) { - // 최초 동기화 - value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - - // 포그라운드 복귀 시 OS 권한 동기화 - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - if (value) { - state.eventSink(OcrUiEvent.OnHidePermissionDialog) - } else { - state.eventSink(OcrUiEvent.OnShowPermissionDialog) - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - awaitDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> - if (!granted) { - state.eventSink(OcrUiEvent.OnShowPermissionDialog) - } - } - val settingsLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { _ -> } - - // 최초 진입 시 권한 요청 - LaunchedEffect(Unit) { - if (!isGranted) { - state.eventSink(OcrUiEvent.OnHidePermissionDialog) - permissionLauncher.launch(permission) - } - } - - /** - * Camera Controller - */ - val cameraController = remember { LifecycleCameraController(context) } - - DisposableEffect(isGranted, lifecycleOwner, cameraController) { - if (isGranted) { - cameraController.bindToLifecycle(lifecycleOwner) - } - - onDispose { - cameraController.unbind() - } - } - - /** - * SystemStatusBar Color - */ - val systemUiController = rememberSystemUiController() - - DisposableEffect(systemUiController) { - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = false, - isNavigationBarContrastEnforced = false, - ) - - onDispose { - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = true, - isNavigationBarContrastEnforced = false, - ) - } - } - - ReedScaffold( - modifier = modifier.fillMaxSize(), - containerColor = Neutral950, - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - ReedCloseTopAppBar( - modifier = Modifier - .background(color = Color.Black) - .align(Alignment.TopCenter), - isDark = true, - onClose = { - state.eventSink(OcrUiEvent.OnCloseClick) - }, - ) - Text( - text = stringResource(R.string.ocr_guide), - modifier = Modifier - .align(Alignment.Center) - .offset { - IntOffset( - x = 0, - y = (-164).dp.roundToPx(), - ) - }, - color = ReedTheme.colors.contentInverse, - textAlign = TextAlign.Center, - style = ReedTheme.typography.headline2Medium, - ) - - if (isGranted) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(color = White) - .height(200.dp) - .align(Alignment.Center), - ) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - PreviewView(context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - clipToOutline = true - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - scaleType = PreviewView.ScaleType.FILL_CENTER - controller = cameraController - } - }, - ) - } - CameraFrame(modifier = Modifier.align(Alignment.Center)) - } - - Column( - modifier = Modifier.align(Alignment.BottomCenter), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (state.isTextDetectionFailed) { - Text( - text = stringResource(R.string.ocr_error_text_detection_failed), - color = ReedTheme.colors.contentError, - textAlign = TextAlign.Center, - style = ReedTheme.typography.label2Regular, - ) - Spacer(modifier = Modifier.height(22.dp)) - } - - Button( - enabled = !state.isLoading, - onClick = { - state.eventSink(OcrUiEvent.OnCaptureStart) - - val executor = ContextCompat.getMainExecutor(context) - val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir) - val output = ImageCapture.OutputFileOptions.Builder(photoFile).build() - - cameraController.takePicture( - output, - executor, - object : ImageCapture.OnImageSavedCallback { - override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { - state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) - } - - override fun onError(exception: ImageCaptureException) { - state.eventSink(OcrUiEvent.OnCaptureFailed(exception)) - } - }, - ) - }, - modifier = Modifier.size(72.dp), - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = ReedTheme.colors.bgPrimary, - contentColor = White, - ), - contentPadding = PaddingValues(ReedTheme.spacing.spacing0), - ) { - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize), - contentDescription = "Scan Icon", - modifier = Modifier.size(ReedTheme.spacing.spacing8), - ) - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) - } - - if (state.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } - } - } - } - - if (state.isPermissionDialogVisible) { - ReedDialog( - title = stringResource(R.string.permission_dialog_title), - description = stringResource(R.string.permission_dialog_description), - confirmButtonText = stringResource(R.string.permission_dialog_move_to_settings), - onConfirmRequest = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - settingsLauncher.launch(intent) - }, - ) - } -} - -@TraceRecomposition -@Composable -private fun TextScanResult( - state: OcrUiState, - modifier: Modifier = Modifier, -) { - ReedScaffold( - modifier = modifier.fillMaxSize(), - containerColor = White, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - ReedCloseTopAppBar( - title = stringResource(R.string.ocr_sentence_selection), - onClose = { - state.eventSink(OcrUiEvent.OnCloseClick) - }, - ) - LazyColumn( - modifier = Modifier - .weight(1f) - .padding(horizontal = ReedTheme.spacing.spacing5), - verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), - ) { - item { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) - } - - items(state.sentenceList.size) { index -> - SentenceBox( - onClick = { - state.eventSink(OcrUiEvent.OnSentenceSelected(index)) - }, - sentence = state.sentenceList[index], - isSelected = state.selectedIndices.contains(index), - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - ) { - ReedButton( - onClick = { - state.eventSink(OcrUiEvent.OnReCaptureButtonClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.SECONDARY, - modifier = Modifier.weight(1f), - text = stringResource(R.string.ocr_recapture), - ) - Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) - ReedButton( - onClick = { - state.eventSink(OcrUiEvent.OnSelectionConfirmed) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - enabled = state.selectedIndices.isNotEmpty(), - modifier = Modifier.weight(1f), - text = stringResource(R.string.ocr_selection_confirm), - ) - } - } - } - - if (state.isRecaptureDialogVisible) { - ReedDialog( - title = stringResource(R.string.recapture_dialog_title), - description = stringResource(R.string.recapture_dialog_description), - confirmButtonText = stringResource(R.string.recapture_dialog_confirm), - onConfirmRequest = { - state.eventSink(OcrUiEvent.OnRecaptureDialogConfirmed) - }, - dismissButtonText = stringResource(R.string.recapture_dialog_cancel), - onDismissRequest = { - state.eventSink(OcrUiEvent.OnRecaptureDialogDismissed) - }, - ) - } -} - -@ComponentPreview -@Composable -private fun CameraPreviewPreview() { - ReedTheme { - CameraPreview( - state = OcrUiState( - eventSink = {}, - ), - ) - } -} - -@ComponentPreview -@Composable -private fun TextRecognitionResultPreview() { - ReedTheme { - TextScanResult( - state = OcrUiState( - eventSink = {}, - ), - ) + OcrUi.CAMERA -> OcrCameraContent(state = state, modifier = modifier) + OcrUi.IMAGE -> OcrImageContent(state = state, modifier = modifier) + OcrUi.RESULT -> OcrResultContent(state = state, modifier = modifier) } } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt index 7b932e0b..4fa6fa66 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt @@ -2,6 +2,7 @@ package com.ninecraft.booket.feature.record.ocr import android.net.Uri import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.common.utils.UiText import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList @@ -13,9 +14,12 @@ import java.util.UUID data class OcrUiState( val currentUi: OcrUi = OcrUi.CAMERA, val isPermissionDialogVisible: Boolean = false, + val selectedImage: String = "", val sentenceList: ImmutableList = persistentListOf(), val selectedIndices: ImmutableSet = persistentSetOf(), val isTextDetectionFailed: Boolean = false, + val isCameraRecognitionFailedDialogVisible: Boolean = false, + val isGalleryRecognitionFailedDialogVisible: Boolean = false, val isRecaptureDialogVisible: Boolean = false, val isLoading: Boolean = false, val sideEffect: OcrSideEffect? = null, @@ -25,26 +29,36 @@ data class OcrUiState( @Immutable sealed interface OcrSideEffect { data class ShowToast( - val message: String, + val message: UiText, private val key: String = UUID.randomUUID().toString(), ) : OcrSideEffect } sealed interface OcrUiEvent : CircuitUiEvent { data object OnCloseClick : OcrUiEvent + data object OnImageContentClosed : OcrUiEvent data object OnShowPermissionDialog : OcrUiEvent data object OnHidePermissionDialog : OcrUiEvent data object OnCaptureStart : OcrUiEvent data class OnCaptureFailed(val exception: Exception) : OcrUiEvent data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent + data class OnImageSelected(val imageUri: String) : OcrUiEvent data object OnReCaptureButtonClick : OcrUiEvent data object OnSelectionConfirmed : OcrUiEvent data object OnRecaptureDialogConfirmed : OcrUiEvent data object OnRecaptureDialogDismissed : OcrUiEvent + data object OnCameraRecognitionFailedDialogDismissed : OcrUiEvent + data object OnImageRecognitionFailedDialogDismissed : OcrUiEvent data class OnSentenceSelected(val index: Int) : OcrUiEvent } enum class OcrUi { CAMERA, + IMAGE, RESULT, } + +enum class RecognizeSource { + CAMERA, + GALLERY, +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt new file mode 100644 index 00000000..d6536047 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt @@ -0,0 +1,89 @@ +package com.ninecraft.booket.feature.record.ocr.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +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 +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.Neutral900 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.designsystem.R as designR + +@Composable +internal fun CameraBottomBar( + onGalleryClick: () -> Unit, + onCaptureClick: () -> Unit, + buttonEnabled: Boolean = true, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing6), + ) { + IconButton( + onClick = onGalleryClick, + modifier = Modifier + .size(ReedTheme.spacing.spacing12) + .align(Alignment.CenterStart), + enabled = buttonEnabled, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Neutral900, + contentColor = White, + ), + shape = RoundedCornerShape(ReedTheme.radius.sm), + ) { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_gallery), + contentDescription = "Gallery Icon", + modifier = Modifier + .size(ReedTheme.spacing.spacing6), + ) + } + Button( + onClick = onCaptureClick, + modifier = Modifier + .size(72.dp) + .align(Alignment.Center), + enabled = buttonEnabled, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = ReedTheme.colors.bgPrimary, + contentColor = White, + ), + contentPadding = PaddingValues(ReedTheme.spacing.spacing0), + ) { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize), + contentDescription = "Scan Icon", + modifier = Modifier.size(ReedTheme.spacing.spacing8), + ) + } + } +} + +@ComponentPreview +@Composable +private fun CameraBottomBarPreview() { + ReedTheme { + CameraBottomBar( + onGalleryClick = {}, + onCaptureClick = {}, + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt new file mode 100644 index 00000000..3d8473a4 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt @@ -0,0 +1,85 @@ +package com.ninecraft.booket.feature.record.ocr.component + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.Green100 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +internal fun ImageProcessingLoader( + modifier: Modifier = Modifier, +) { + val infiniteTransition = rememberInfiniteTransition(label = "loading") + + // 점들의 애니메이션 상태 리스트 (0ms, 300ms, 600ms 지연) + val dotAnimations = listOf(0, 1, 2).map { index -> + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0.0f at 0 using LinearOutSlowInEasing // 시작 + 1.0f at 300 using FastOutLinearInEasing // 최고점 + 0.0f at 600 using LinearOutSlowInEasing // 바닥 도착 + 0.0f at 1200 // 대기 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(index * 200), // 순차적 시작 지연 + ), + label = "dot_y_offset", + ) + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + dotAnimations.forEachIndexed { index, animValue -> + Box( + modifier = Modifier + .size(ReedTheme.spacing.spacing3) + // 애니메이션 값에 따라 y축 좌표 이동 + .graphicsLayer { + translationY = -animValue.value * 12.dp.toPx() + } + .background( + color = if (animValue.value > 0f) ReedTheme.colors.contentBrand else Green100, + shape = CircleShape, + ), + ) + + if (index < 2) { + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing3)) + } + } + } +} + +@ComponentPreview +@Composable +private fun ImageProcessingLoaderPreview() { + ReedTheme { + ImageProcessingLoader() + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt new file mode 100644 index 00000000..e1daf915 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt @@ -0,0 +1,322 @@ +package com.ninecraft.booket.feature.record.ocr.content + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +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.Column +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.Neutral950 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.CameraBottomBar +import com.ninecraft.booket.feature.record.ocr.component.CameraFrame +import com.skydoves.compose.stability.runtime.TraceRecomposition +import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController +import java.io.File + +@TraceRecomposition +@Composable +internal fun OcrCameraContent( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val permission = android.Manifest.permission.CAMERA + + /** + * Camera Permission Request + */ + val isGranted by produceState( + initialValue = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, + key1 = lifecycleOwner, // lifecycle 변경 시 재설정 + ) { + // 최초 동기화 + value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + // 포그라운드 복귀 시 OS 권한 동기화 + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + if (value) { + state.eventSink(OcrUiEvent.OnHidePermissionDialog) + } else { + state.eventSink(OcrUiEvent.OnShowPermissionDialog) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + awaitDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (!granted) { + state.eventSink(OcrUiEvent.OnShowPermissionDialog) + } + } + val settingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { _ -> } + + // 최초 진입 시 권한 요청 + LaunchedEffect(Unit) { + if (!isGranted) { + state.eventSink(OcrUiEvent.OnHidePermissionDialog) + permissionLauncher.launch(permission) + } + } + + /** + * Camera Controller + */ + val cameraController = remember { LifecycleCameraController(context) } + + DisposableEffect(isGranted, lifecycleOwner, cameraController) { + if (isGranted) { + cameraController.bindToLifecycle(lifecycleOwner) + } + + onDispose { + cameraController.unbind() + } + } + + /** + * SystemStatusBar Color + */ + val systemUiController = rememberSystemUiController() + + DisposableEffect(systemUiController) { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = false, + isNavigationBarContrastEnforced = false, + ) + + onDispose { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = true, + isNavigationBarContrastEnforced = false, + ) + } + } + + /** + * Image Picker + */ + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + if (uri != null) { + state.eventSink(OcrUiEvent.OnImageSelected(uri.toString())) + } + }, + ) + + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Neutral950, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ReedCloseTopAppBar( + modifier = Modifier + .background(color = Color.Black) + .align(Alignment.TopCenter), + isDark = true, + onClose = { + state.eventSink(OcrUiEvent.OnCloseClick) + }, + ) + Text( + text = stringResource(R.string.ocr_guide), + modifier = Modifier + .align(Alignment.Center) + .offset { + IntOffset( + x = 0, + y = (-164).dp.roundToPx(), + ) + }, + color = ReedTheme.colors.contentInverse, + textAlign = TextAlign.Center, + style = ReedTheme.typography.headline2Medium, + ) + + if (isGranted) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = White) + .height(200.dp) + .align(Alignment.Center), + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + PreviewView(context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + clipToOutline = true + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + controller = cameraController + } + }, + ) + } + CameraFrame(modifier = Modifier.align(Alignment.Center)) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state.isTextDetectionFailed) { + Text( + text = stringResource(R.string.ocr_error_text_detection_failed), + color = ReedTheme.colors.contentError, + textAlign = TextAlign.Center, + style = ReedTheme.typography.label2Regular, + ) + Spacer(modifier = Modifier.height(22.dp)) + } + CameraBottomBar( + onGalleryClick = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + onCaptureClick = { + state.eventSink(OcrUiEvent.OnCaptureStart) + + val executor = ContextCompat.getMainExecutor(context) + val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir) + val output = ImageCapture.OutputFileOptions.Builder(photoFile).build() + + cameraController.takePicture( + output, + executor, + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) + } + + override fun onError(exception: ImageCaptureException) { + state.eventSink(OcrUiEvent.OnCaptureFailed(exception)) + } + }, + ) + }, + buttonEnabled = !state.isLoading, + ) + + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } + + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } + } + } + } + + if (state.isPermissionDialogVisible) { + ReedDialog( + title = stringResource(R.string.permission_dialog_title), + description = stringResource(R.string.permission_dialog_description), + confirmButtonText = stringResource(R.string.permission_dialog_move_to_settings), + onConfirmRequest = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + settingsLauncher.launch(intent) + }, + ) + } + + if (state.isCameraRecognitionFailedDialogVisible) { + ReedDialog( + title = stringResource(R.string.ocr_recognition_failed_dialog_title), + description = stringResource(R.string.ocr_recognition_failed_dialog_description), + confirmButtonText = stringResource(R.string.ocr_recognition_failed_dialog_direct_input), + onConfirmRequest = { + state.eventSink(OcrUiEvent.OnCloseClick) + }, + dismissButtonText = stringResource(R.string.ocr_recognition_failed_dialog_camera), + onDismissRequest = { + state.eventSink(OcrUiEvent.OnCameraRecognitionFailedDialogDismissed) + }, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrCameraContentPreview() { + ReedTheme { + OcrCameraContent( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt new file mode 100644 index 00000000..0cb9e773 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt @@ -0,0 +1,125 @@ +package com.ninecraft.booket.feature.record.ocr.content + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.NetworkImage +import com.ninecraft.booket.core.designsystem.theme.Black +import com.ninecraft.booket.core.designsystem.theme.Neutral950 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.ImageProcessingLoader + +@Composable +internal fun OcrImageContent( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + if (uri != null) { + state.eventSink(OcrUiEvent.OnImageSelected(uri.toString())) + } + }, + ) + + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Neutral950, + ) { innerPadding -> + Column( + modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + ReedCloseTopAppBar( + modifier = Modifier + .background(color = Color.Black), + isDark = true, + onClose = { + state.eventSink(OcrUiEvent.OnImageContentClosed) + }, + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + NetworkImage( + imageUrl = state.selectedImage, + contentDescription = "Selected Image", + modifier = Modifier.fillMaxWidth(), + ) + Box( + modifier = Modifier + .matchParentSize() + .background(Black.copy(alpha = 0.5f)), + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.ocr_image_scanning), + color = ReedTheme.colors.contentInverse, + style = ReedTheme.typography.headline2Medium, + ) + Spacer(modifier = Modifier.height(42.dp)) + ImageProcessingLoader() + } + } + } + } + + if (state.isGalleryRecognitionFailedDialogVisible) { + ReedDialog( + title = stringResource(R.string.ocr_recognition_failed_dialog_title), + description = stringResource(R.string.ocr_recognition_failed_dialog_description), + confirmButtonText = stringResource(R.string.ocr_recognition_failed_dialog_direct_input), + onConfirmRequest = { + state.eventSink(OcrUiEvent.OnCloseClick) + }, + dismissButtonText = stringResource(R.string.ocr_recognition_failed_dialog_image), + onDismissRequest = { + state.eventSink(OcrUiEvent.OnImageRecognitionFailedDialogDismissed) + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + dismissOnClickOutside = false, + dismissOnBackPress = false, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrImageContentPreview() { + ReedTheme { + OcrImageContent( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt new file mode 100644 index 00000000..d78c7d63 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt @@ -0,0 +1,130 @@ +package com.ninecraft.booket.feature.record.ocr.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.button.ReedButton +import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle +import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.SentenceBox +import com.skydoves.compose.stability.runtime.TraceRecomposition + +@TraceRecomposition +@Composable +internal fun OcrResultContent( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = White, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ReedCloseTopAppBar( + title = stringResource(R.string.ocr_sentence_selection), + onClose = { + state.eventSink(OcrUiEvent.OnReCaptureButtonClick) + }, + ) + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = ReedTheme.spacing.spacing5), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + ) { + item { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + } + + items(state.sentenceList.size) { index -> + SentenceBox( + onClick = { + state.eventSink(OcrUiEvent.OnSentenceSelected(index)) + }, + sentence = state.sentenceList[index], + isSelected = state.selectedIndices.contains(index), + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + ) { + ReedButton( + onClick = { + state.eventSink(OcrUiEvent.OnReCaptureButtonClick) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.weight(1f), + text = stringResource(R.string.ocr_recapture), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + ReedButton( + onClick = { + state.eventSink(OcrUiEvent.OnSelectionConfirmed) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + enabled = state.selectedIndices.isNotEmpty(), + modifier = Modifier.weight(1f), + text = stringResource(R.string.ocr_selection_confirm), + ) + } + } + } + + if (state.isRecaptureDialogVisible) { + ReedDialog( + title = stringResource(R.string.recapture_dialog_title), + description = stringResource(R.string.recapture_dialog_description), + confirmButtonText = stringResource(R.string.recapture_dialog_confirm), + onConfirmRequest = { + state.eventSink(OcrUiEvent.OnRecaptureDialogConfirmed) + }, + dismissButtonText = stringResource(R.string.recapture_dialog_cancel), + onDismissRequest = { + state.eventSink(OcrUiEvent.OnRecaptureDialogDismissed) + }, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrResultContentPreview() { + ReedTheme { + OcrResultContent( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml index 3929d085..2bc8c7fa 100644 --- a/feature/record/src/main/res/values/strings.xml +++ b/feature/record/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ 선택 완료 다시 촬영하기 문장을 인식하지 못했어요\n다시 한 번 촬영해주세요 + 이미지 스캔 중입니다\n잠시만 기다려 주세요 다시 촬영하시겠어요? 선택한 문장은 삭제될 예정입니다. 확인 @@ -50,4 +51,10 @@ 선택 완료 감정을 수정하시겠어요? 기록된 감정이 삭제됩니다. + 문장을 인식하지 못했어요 + 직접 문장을 입력하시겠어요? + 직접 입력하기 + 다시 촬영하기 + 이미지 선택하기 + 이미지 캡처에 실패했어요