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 @@
선택 완료
감정을 수정하시겠어요?
기록된 감정이 삭제됩니다.
+ 문장을 인식하지 못했어요
+ 직접 문장을 입력하시겠어요?
+ 직접 입력하기
+ 다시 촬영하기
+ 이미지 선택하기
+ 이미지 캡처에 실패했어요