From d271be6ed4ad969b86307138f040d029f3e08dcf Mon Sep 17 00:00:00 2001 From: seoyoon Date: Fri, 16 Jan 2026 00:46:19 +0900 Subject: [PATCH 01/11] =?UTF-8?q?[BOOK-491]=20feat:=20=EC=B9=B4=EB=A9=94?= =?UTF-8?q?=EB=9D=BC=20=ED=95=98=EB=8B=A8=20=EC=98=81=EC=97=AD=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/res/drawable/ic_gallery.xml | 9 ++ .../booket/feature/record/ocr/OcrUi.kt | 39 +++----- .../record/ocr/component/CameraBottomBar.kt | 89 +++++++++++++++++++ 3 files changed, 108 insertions(+), 29 deletions(-) create mode 100644 core/designsystem/src/main/res/drawable/ic_gallery.xml create mode 100644 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt 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/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..b5c3a2c9 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 @@ -16,7 +16,6 @@ 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 @@ -24,14 +23,9 @@ 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 @@ -42,10 +36,8 @@ 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 @@ -66,6 +58,7 @@ 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.CameraBottomBar import com.ninecraft.booket.feature.record.ocr.component.CameraFrame import com.ninecraft.booket.feature.record.ocr.component.SentenceBox import com.ninecraft.booket.feature.screens.OcrScreen @@ -74,7 +67,6 @@ 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) @@ -254,10 +246,11 @@ private fun CameraPreview( ) Spacer(modifier = Modifier.height(22.dp)) } - - Button( - enabled = !state.isLoading, - onClick = { + CameraBottomBar( + onGalleryClick = { + // TODO: 갤러리 + }, + onCaptureClick = { state.eventSink(OcrUiEvent.OnCaptureStart) val executor = ContextCompat.getMainExecutor(context) @@ -265,8 +258,7 @@ private fun CameraPreview( val output = ImageCapture.OutputFileOptions.Builder(photoFile).build() cameraController.takePicture( - output, - executor, + output, executor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) @@ -278,20 +270,9 @@ private fun CameraPreview( }, ) }, - 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), - ) - } + buttonEnabled = !state.isLoading, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) } 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 = {}, + ) + } +} From 96a7fbf99d65759119bb4d3e52002824bf73dae4 Mon Sep 17 00:00:00 2001 From: seoyoon Date: Fri, 16 Jan 2026 02:46:10 +0900 Subject: [PATCH 02/11] =?UTF-8?q?[BOOK-491]=20feat:=20=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20OCR=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OCR 화면의 UI 상태에 따라 `OcrCameraView`, `OcrImageView`, `OcrResultView`로 뷰를 분리하여 구조를 개선 --- .../booket/feature/record/ocr/OcrPresenter.kt | 17 +- .../booket/feature/record/ocr/OcrUi.kt | 342 +----------------- .../booket/feature/record/ocr/OcrUiState.kt | 4 + .../feature/record/ocr/view/OcrCameraView.kt | 306 ++++++++++++++++ .../feature/record/ocr/view/OcrImageView.kt | 74 ++++ .../feature/record/ocr/view/OcrResultView.kt | 130 +++++++ 6 files changed, 537 insertions(+), 336 deletions(-) create mode 100644 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt create mode 100644 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt create mode 100644 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt 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..cd62ccd7 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 @@ -9,7 +9,9 @@ import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer +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 @@ -48,6 +50,7 @@ class OcrPresenter( val scope = rememberCoroutineScope() 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("") } @@ -118,7 +121,7 @@ class OcrPresenter( is OcrUiEvent.OnCaptureFailed -> { isLoading = false - sideEffect = OcrSideEffect.ShowToast("이미지 캡처에 실패했어요") + sideEffect = ShowToast("이미지 캡처에 실패했어요") Logger.e("ImageCaptureException: ${event.exception.message}") } @@ -128,6 +131,11 @@ class OcrPresenter( recognizeText(event.imageUri) } + is OcrUiEvent.OnImageSelected -> { + currentUi = OcrUi.IMAGE + selectedImage = event.imageUri + } + is OcrUiEvent.OnReCaptureButtonClick -> { isRecaptureDialogVisible = true } @@ -135,7 +143,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 +163,10 @@ class OcrPresenter( is OcrUiEvent.OnRecaptureDialogDismissed -> { isRecaptureDialogVisible = false } + + OcrUiEvent.OnImageViewClosed -> { + currentUi = OcrUi.CAMERA + } } } @@ -165,6 +177,7 @@ class OcrPresenter( return OcrUiState( currentUi = currentUi, isPermissionDialogVisible = isPermissionDialogVisible, + selectedImage = selectedImage, sentenceList = sentenceList, selectedIndices = selectedIndices, isTextDetectionFailed = isTextDetectionFailed, 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 b5c3a2c9..16fb8f5e 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 @@ -7,6 +7,7 @@ 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 @@ -48,6 +49,7 @@ 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.NetworkImage 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 @@ -61,6 +63,9 @@ import com.ninecraft.booket.feature.record.R import com.ninecraft.booket.feature.record.ocr.component.CameraBottomBar 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.view.OcrCameraView +import com.ninecraft.booket.feature.record.ocr.view.OcrImageView +import com.ninecraft.booket.feature.record.ocr.view.OcrResultView import com.ninecraft.booket.feature.screens.OcrScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject @@ -78,339 +83,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)) - } - CameraBottomBar( - onGalleryClick = { - // TODO: 갤러리 - }, - 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) - }, - ) - } -} - -@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 -> OcrCameraView(state = state, modifier = modifier) + OcrUi.IMAGE -> OcrImageView(state = state, modifier = modifier) + OcrUi.RESULT -> OcrResultView(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..773a87e0 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 @@ -13,6 +13,7 @@ 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, @@ -32,11 +33,13 @@ sealed interface OcrSideEffect { sealed interface OcrUiEvent : CircuitUiEvent { data object OnCloseClick : OcrUiEvent + data object OnImageViewClosed : 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 @@ -46,5 +49,6 @@ sealed interface OcrUiEvent : CircuitUiEvent { enum class OcrUi { CAMERA, + IMAGE, RESULT, } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt new file mode 100644 index 00000000..8ce2e856 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt @@ -0,0 +1,306 @@ +package com.ninecraft.booket.feature.record.ocr.view + +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 OcrCameraView( + 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) + }, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrCameraViewPreview() { + ReedTheme { + OcrCameraView( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt new file mode 100644 index 00000000..8e310d7e --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt @@ -0,0 +1,74 @@ +package com.ninecraft.booket.feature.record.ocr.view + +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.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.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.ImageProcessingLoader + +@Composable +internal fun OcrImageView( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + 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.OnImageViewClosed) + }, + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + NetworkImage( + imageUrl = state.selectedImage, + contentDescription = "Selected Image", + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun OcrImageViewPreview() { + ReedTheme { + OcrImageView( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt new file mode 100644 index 00000000..9d35fc76 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt @@ -0,0 +1,130 @@ +package com.ninecraft.booket.feature.record.ocr.view + +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 OcrResultView( + 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 OcrResultViewPreview() { + ReedTheme { + OcrResultView( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} From 7f95b1811fb63faf74bf674e3cb6cfc59514bd46 Mon Sep 17 00:00:00 2001 From: seoyoon Date: Fri, 16 Jan 2026 02:48:10 +0900 Subject: [PATCH 03/11] =?UTF-8?q?[BOOK-491]=20feat:=20OCR=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=8A=A4=EC=BA=94=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ocr/component/ImageProcessingLoader.kt | 85 +++++++++++++++++++ .../feature/record/ocr/view/OcrImageView.kt | 18 ++++ .../record/src/main/res/values/strings.xml | 1 + 3 files changed, 104 insertions(+) create mode 100644 feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt 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..9b3c4d7e --- /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/view/OcrImageView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt index 8e310d7e..d66edca9 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt @@ -13,6 +13,7 @@ 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 @@ -21,6 +22,7 @@ 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.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 @@ -56,6 +58,22 @@ internal fun OcrImageView( 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() + } } } } diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml index 3929d085..dde8f121 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잠시만 기다려 주세요 다시 촬영하시겠어요? 선택한 문장은 삭제될 예정입니다. 확인 From b7c5a33d864bb5c984ee23cb470526203739d78a Mon Sep 17 00:00:00 2001 From: seoyoon Date: Wed, 21 Jan 2026 21:14:14 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[BOOK-491]=20feat:=20=EC=9D=B8=EC=8B=9D?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20dialog=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(vi?= =?UTF-8?q?ew=20->=20content)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/button/ReedButton.kt | 3 + .../booket/feature/record/ocr/OcrUi.kt | 75 ++----------------- .../booket/feature/record/ocr/OcrUiState.kt | 2 + .../OcrCameraContent.kt} | 21 +++++- .../OcrImageContent.kt} | 24 +++++- .../OcrResultContent.kt} | 10 +-- .../record/src/main/res/values/strings.xml | 5 ++ 7 files changed, 58 insertions(+), 82 deletions(-) rename feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/{view/OcrCameraView.kt => content/OcrCameraContent.kt} (93%) rename feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/{view/OcrImageView.kt => content/OcrImageContent.kt} (78%) rename feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/{view/OcrResultView.kt => content/OcrResultContent.kt} (95%) 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/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 16fb8f5e..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,77 +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.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.Arrangement -import androidx.compose.foundation.layout.Box -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.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -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.component.NetworkImage -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.CameraBottomBar -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.view.OcrCameraView -import com.ninecraft.booket.feature.record.ocr.view.OcrImageView -import com.ninecraft.booket.feature.record.ocr.view.OcrResultView +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 @TraceRecomposition @CircuitInject(OcrScreen::class, AppScope::class) @@ -83,8 +20,8 @@ internal fun OcrUi( HandleOcrSideEffects(state = state) when (state.currentUi) { - OcrUi.CAMERA -> OcrCameraView(state = state, modifier = modifier) - OcrUi.IMAGE -> OcrImageView(state = state, modifier = modifier) - OcrUi.RESULT -> OcrResultView(state = state, modifier = modifier) + 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 773a87e0..1e2f5612 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 @@ -17,6 +17,8 @@ data class OcrUiState( 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, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt similarity index 93% rename from feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt rename to feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt index 8ce2e856..01f53fc9 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrCameraView.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.record.ocr.view +package com.ninecraft.booket.feature.record.ocr.content import android.content.Intent import android.content.pm.PackageManager @@ -62,7 +62,7 @@ import java.io.File @TraceRecomposition @Composable -internal fun OcrCameraView( +internal fun OcrCameraContent( state: OcrUiState, modifier: Modifier = Modifier, ) { @@ -291,13 +291,26 @@ internal fun OcrCameraView( }, ) } + + 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 = {}, + ) + } } @ComponentPreview @Composable -private fun OcrCameraViewPreview() { +private fun OcrCameraContentPreview() { ReedTheme { - OcrCameraView( + OcrCameraContent( state = OcrUiState( eventSink = {}, ), diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt similarity index 78% rename from feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt rename to feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt index d66edca9..5f94f495 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrImageView.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.record.ocr.view +package com.ninecraft.booket.feature.record.ocr.content import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -22,13 +22,14 @@ 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 OcrImageView( +internal fun OcrImageContent( state: OcrUiState, modifier: Modifier = Modifier, ) { @@ -77,13 +78,28 @@ internal fun OcrImageView( } } } + + 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 = { + // 갤러리 열기 + } + ) + } } @ComponentPreview @Composable -private fun OcrImageViewPreview() { +private fun OcrImageContentPreview() { ReedTheme { - OcrImageView( + OcrImageContent( state = OcrUiState( eventSink = {}, ), diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt similarity index 95% rename from feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt rename to feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt index 9d35fc76..d78c7d63 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/view/OcrResultView.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.record.ocr.view +package com.ninecraft.booket.feature.record.ocr.content import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -30,7 +30,7 @@ import com.skydoves.compose.stability.runtime.TraceRecomposition @TraceRecomposition @Composable -internal fun OcrResultView( +internal fun OcrResultContent( state: OcrUiState, modifier: Modifier = Modifier, ) { @@ -46,7 +46,7 @@ internal fun OcrResultView( ReedCloseTopAppBar( title = stringResource(R.string.ocr_sentence_selection), onClose = { - state.eventSink(OcrUiEvent.OnCloseClick) + state.eventSink(OcrUiEvent.OnReCaptureButtonClick) }, ) LazyColumn( @@ -119,9 +119,9 @@ internal fun OcrResultView( @ComponentPreview @Composable -private fun OcrResultViewPreview() { +private fun OcrResultContentPreview() { ReedTheme { - OcrResultView( + 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 dde8f121..fe1c34eb 100644 --- a/feature/record/src/main/res/values/strings.xml +++ b/feature/record/src/main/res/values/strings.xml @@ -51,4 +51,9 @@ 선택 완료 감정을 수정하시겠어요? 기록된 감정이 삭제됩니다. + 문장을 인식하지 못했어요 + 직접 문장을 입력하시겠어요? + 직접 촬영하기 + 다시 촬영하기 + 이미지 선택하기 From f304d2a7131f39c1a5570cd50e236f457a8d31f1 Mon Sep 17 00:00:00 2001 From: seoyoon Date: Wed, 21 Jan 2026 21:37:11 +0900 Subject: [PATCH 05/11] =?UTF-8?q?[BOOK-491]=20feat:=20=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=84=9C=20=EC=84=A0=ED=83=9D=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20OCR=20=EC=9D=B8=EC=8B=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/ocr/recognizer/CloudOcrRecognizer.kt | 17 ++++++++++++++--- .../booket/feature/record/ocr/OcrPresenter.kt | 4 ++++ 2 files changed, 18 insertions(+), 3 deletions(-) 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/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 cd62ccd7..1193945d 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue 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.handleException import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer @@ -134,6 +135,9 @@ class OcrPresenter( is OcrUiEvent.OnImageSelected -> { currentUi = OcrUi.IMAGE selectedImage = event.imageUri + + val pareUri = selectedImage.toUri() + recognizeText(pareUri) } is OcrUiEvent.OnReCaptureButtonClick -> { From ef0c3001b7df151d2ec5c1a037bce959a23e2799 Mon Sep 17 00:00:00 2001 From: seoyoon Date: Wed, 21 Jan 2026 22:25:07 +0900 Subject: [PATCH 06/11] =?UTF-8?q?[BOOK-491]=20feat:=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B8=EC=8B=9D=20=EC=8B=A4=ED=8C=A8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booket/feature/record/ocr/OcrPresenter.kt | 81 +++++++++++++++---- .../booket/feature/record/ocr/OcrUiState.kt | 9 ++- .../record/ocr/content/OcrCameraContent.kt | 4 +- .../record/ocr/content/OcrImageContent.kt | 21 ++++- .../record/src/main/res/values/strings.xml | 2 +- 5 files changed, 96 insertions(+), 21 deletions(-) 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 1193945d..5560db4c 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,6 +2,7 @@ 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.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope @@ -27,6 +28,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 @@ -44,6 +46,7 @@ class OcrPresenter( companion object { private const val RECORD_OCR_SENTENCE = "record_OCR_sentence" + private const val CAMERA_MAX_FAILURES = 2 } @Composable @@ -56,11 +59,53 @@ class OcrPresenter( 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 sideEffect by rememberRetained { mutableStateOf(null) } - fun recognizeText(imageUri: Uri) { + var cameraFailureCount by rememberRetained { mutableStateOf(0) } + + 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 @@ -69,21 +114,13 @@ 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}") @@ -128,16 +165,20 @@ class OcrPresenter( is OcrUiEvent.OnImageCaptured -> { isTextDetectionFailed = false + isCameraRecognitionFailedDialogVisible = false + isGalleryRecognitionFailedDialogVisible = false - recognizeText(event.imageUri) + recognizeText(event.imageUri, RecognizeSource.CAMERA) } is OcrUiEvent.OnImageSelected -> { currentUi = OcrUi.IMAGE selectedImage = event.imageUri + isTextDetectionFailed = false + isGalleryRecognitionFailedDialogVisible = false val pareUri = selectedImage.toUri() - recognizeText(pareUri) + recognizeText(pareUri, RecognizeSource.GALLERY) } is OcrUiEvent.OnReCaptureButtonClick -> { @@ -168,9 +209,17 @@ class OcrPresenter( isRecaptureDialogVisible = false } - OcrUiEvent.OnImageViewClosed -> { + OcrUiEvent.OnImageContentClosed -> { currentUi = OcrUi.CAMERA } + + OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> { + isCameraRecognitionFailedDialogVisible = false + } + + OcrUiEvent.OnImageRecognitionFailedDialogDismissed -> { + isGalleryRecognitionFailedDialogVisible = false + } } } @@ -185,6 +234,8 @@ class OcrPresenter( 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/OcrUiState.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt index 1e2f5612..5530638c 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 @@ -35,7 +35,7 @@ sealed interface OcrSideEffect { sealed interface OcrUiEvent : CircuitUiEvent { data object OnCloseClick : OcrUiEvent - data object OnImageViewClosed : OcrUiEvent + data object OnImageContentClosed : OcrUiEvent data object OnShowPermissionDialog : OcrUiEvent data object OnHidePermissionDialog : OcrUiEvent data object OnCaptureStart : OcrUiEvent @@ -46,6 +46,8 @@ sealed interface OcrUiEvent : CircuitUiEvent { 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 } @@ -54,3 +56,8 @@ enum class OcrUi { IMAGE, RESULT, } + +enum class RecognizeSource { + CAMERA, + GALLERY, +} 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 index 01f53fc9..1e6a8c96 100644 --- 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 @@ -301,7 +301,9 @@ internal fun OcrCameraContent( state.eventSink(OcrUiEvent.OnCloseClick) }, dismissButtonText = stringResource(R.string.ocr_recognition_failed_dialog_camera), - onDismissRequest = {}, + onDismissRequest = { + state.eventSink(OcrUiEvent.OnCameraRecognitionFailedDialogDismissed) + }, ) } } 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 index 5f94f495..ed239678 100644 --- 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 @@ -1,5 +1,8 @@ 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 @@ -33,6 +36,15 @@ 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, @@ -47,7 +59,7 @@ internal fun OcrImageContent( .background(color = Color.Black), isDark = true, onClose = { - state.eventSink(OcrUiEvent.OnImageViewClosed) + state.eventSink(OcrUiEvent.OnImageContentClosed) }, ) Box( @@ -89,8 +101,11 @@ internal fun OcrImageContent( }, dismissButtonText = stringResource(R.string.ocr_recognition_failed_dialog_image), onDismissRequest = { - // 갤러리 열기 - } + state.eventSink(OcrUiEvent.OnImageRecognitionFailedDialogDismissed) + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, ) } } diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml index fe1c34eb..fe3812a8 100644 --- a/feature/record/src/main/res/values/strings.xml +++ b/feature/record/src/main/res/values/strings.xml @@ -53,7 +53,7 @@ 기록된 감정이 삭제됩니다. 문장을 인식하지 못했어요 직접 문장을 입력하시겠어요? - 직접 촬영하기 + 직접 입력하기 다시 촬영하기 이미지 선택하기 From 65b7f39ab0eecd3bcc47d2f43620760b0b398fb5 Mon Sep 17 00:00:00 2001 From: seoyoon Date: Wed, 21 Jan 2026 22:27:22 +0900 Subject: [PATCH 07/11] [BOOK-491] chore: code style check success --- .../feature/record/ocr/component/ImageProcessingLoader.kt | 6 +++--- .../booket/feature/record/ocr/content/OcrCameraContent.kt | 3 ++- .../booket/feature/record/ocr/content/OcrImageContent.kt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) 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 index 9b3c4d7e..3d8473a4 100644 --- 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 @@ -39,10 +39,10 @@ internal fun ImageProcessingLoader( animationSpec = infiniteRepeatable( animation = keyframes { durationMillis = 1200 - 0.0f at 0 using LinearOutSlowInEasing // 시작 + 0.0f at 0 using LinearOutSlowInEasing // 시작 1.0f at 300 using FastOutLinearInEasing // 최고점 0.0f at 600 using LinearOutSlowInEasing // 바닥 도착 - 0.0f at 1200 // 대기 + 0.0f at 1200 // 대기 }, repeatMode = RepeatMode.Restart, initialStartOffset = StartOffset(index * 200), // 순차적 시작 지연 @@ -53,7 +53,7 @@ internal fun ImageProcessingLoader( Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Center, ) { dotAnimations.forEachIndexed { index, animValue -> Box( 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 index 1e6a8c96..e1daf915 100644 --- 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 @@ -249,7 +249,8 @@ internal fun OcrCameraContent( val output = ImageCapture.OutputFileOptions.Builder(photoFile).build() cameraController.takePicture( - output, executor, + output, + executor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) 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 index ed239678..33d5cd54 100644 --- 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 @@ -74,7 +74,7 @@ internal fun OcrImageContent( Box( modifier = Modifier .matchParentSize() - .background(Black.copy(alpha = 0.5f)) + .background(Black.copy(alpha = 0.5f)), ) Column( horizontalAlignment = Alignment.CenterHorizontally, From 237de4a3875d3a3613ddabb558cb4672bd19d319 Mon Sep 17 00:00:00 2001 From: seoyoon Date: Wed, 21 Jan 2026 23:14:04 +0900 Subject: [PATCH 08/11] =?UTF-8?q?[BOOK-491]=20feat:=20ReedDialog=EC=97=90?= =?UTF-8?q?=20dismissOnClickOutside,=20dismissOnBackPress=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ninecraft/booket/core/ui/component/ReedDialog.kt | 4 ++++ .../booket/feature/record/ocr/content/OcrImageContent.kt | 2 ++ 2 files changed, 6 insertions(+) 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/content/OcrImageContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt index 33d5cd54..0cb9e773 100644 --- 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 @@ -106,6 +106,8 @@ internal fun OcrImageContent( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), ) }, + dismissOnClickOutside = false, + dismissOnBackPress = false, ) } } From 53a4508ca64a77dfdc39ede0dfd2392f53e14ef6 Mon Sep 17 00:00:00 2001 From: easyhooon Date: Thu, 22 Jan 2026 21:59:04 +0900 Subject: [PATCH 09/11] =?UTF-8?q?[BOOK-491]=20refactor:=20Presenter=20?= =?UTF-8?q?=EB=82=B4=EC=97=90=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EB=90=9C=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booket/feature/record/ocr/HandleOcrSideEffects.kt | 2 +- .../com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | 6 ++++-- .../com/ninecraft/booket/feature/record/ocr/OcrUiState.kt | 3 ++- feature/record/src/main/res/values/strings.xml | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) 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 5560db4c..a17b3d57 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 @@ -9,8 +9,10 @@ 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 @@ -124,7 +126,7 @@ class OcrPresenter( val handleErrorMessage = { message: String -> Logger.e("Cloud Vision API Error: ${exception.message}") - sideEffect = OcrSideEffect.ShowToast(message) + sideEffect = ShowToast(UiText.DirectString(message)) } handleException( @@ -159,7 +161,7 @@ class OcrPresenter( is OcrUiEvent.OnCaptureFailed -> { isLoading = false - sideEffect = ShowToast("이미지 캡처에 실패했어요") + sideEffect = ShowToast(UiText.StringResource(R.string.ocr_capture_failed)) Logger.e("ImageCaptureException: ${event.exception.message}") } 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 5530638c..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 @@ -28,7 +29,7 @@ data class OcrUiState( @Immutable sealed interface OcrSideEffect { data class ShowToast( - val message: String, + val message: UiText, private val key: String = UUID.randomUUID().toString(), ) : OcrSideEffect } diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml index fe3812a8..2bc8c7fa 100644 --- a/feature/record/src/main/res/values/strings.xml +++ b/feature/record/src/main/res/values/strings.xml @@ -56,4 +56,5 @@ 직접 입력하기 다시 촬영하기 이미지 선택하기 + 이미지 캡처에 실패했어요 From 7af19b7c23dbf88ba67c27e7077bd619a3c77e55 Mon Sep 17 00:00:00 2001 From: easyhooon Date: Thu, 22 Jan 2026 22:02:09 +0900 Subject: [PATCH 10/11] =?UTF-8?q?[BOOK-491]=20refactor:=20=EC=9B=90?= =?UTF-8?q?=EC=8B=9C=20=ED=83=80=EC=9E=85=20State=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9=20(boxing=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=ED=97=A4=EB=93=9C=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mutableStateOf: 제네릭이라 int → Integer 박싱 필요 - mutableIntStateOf: int를 직접 저장하여 박싱 불필요 --- .../com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 a17b3d57..e120a291 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 @@ -4,6 +4,7 @@ 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 @@ -54,6 +55,7 @@ class OcrPresenter( @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("") } @@ -64,11 +66,9 @@ class OcrPresenter( 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) } - var cameraFailureCount by rememberRetained { mutableStateOf(0) } - LaunchedEffect(isTextDetectionFailed) { if (isTextDetectionFailed) { delay(2000) From d88943235e6966c0673fb8819028d190a5e7dc9c Mon Sep 17 00:00:00 2001 From: seoyoon Date: Fri, 23 Jan 2026 00:13:40 +0900 Subject: [PATCH 11/11] =?UTF-8?q?[BOOK-491]=20feat:=20=EC=9D=B8=EC=8B=9D?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=9E=91=EC=97=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ninecraft/booket/feature/record/ocr/OcrPresenter.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 5560db4c..11cf80c2 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 @@ -94,7 +94,7 @@ class OcrPresenter( isTextDetectionFailed = true cameraFailureCount += 1 - if (cameraFailureCount >= CAMERA_MAX_FAILURES) { + if (cameraFailureCount > CAMERA_MAX_FAILURES) { isCameraRecognitionFailedDialogVisible = true } } @@ -165,8 +165,6 @@ class OcrPresenter( is OcrUiEvent.OnImageCaptured -> { isTextDetectionFailed = false - isCameraRecognitionFailedDialogVisible = false - isGalleryRecognitionFailedDialogVisible = false recognizeText(event.imageUri, RecognizeSource.CAMERA) } @@ -175,7 +173,7 @@ class OcrPresenter( currentUi = OcrUi.IMAGE selectedImage = event.imageUri isTextDetectionFailed = false - isGalleryRecognitionFailedDialogVisible = false + cameraFailureCount = 0 val pareUri = selectedImage.toUri() recognizeText(pareUri, RecognizeSource.GALLERY) @@ -215,6 +213,7 @@ class OcrPresenter( OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> { isCameraRecognitionFailedDialogVisible = false + cameraFailureCount = 0 } OcrUiEvent.OnImageRecognitionFailedDialogDismissed -> {