diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/button/AppRoundButton.kt b/core/design-system/src/main/java/com/twix/designsystem/components/button/AppRoundButton.kt new file mode 100644 index 00000000..d2df0a12 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/button/AppRoundButton.kt @@ -0,0 +1,108 @@ +package com.twix.designsystem.components.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle + +@Composable +fun AppRoundButton( + text: String, + textColor: Color, + modifier: Modifier = Modifier, + textStyle: AppTextStyle = AppTextStyle.T2, + backgroundColor: Color, + borderColor: Color = GrayColor.C500, + hasBorder: Boolean = true, +) { + Box( + modifier = modifier, + ) { + if (hasBorder) { + Box( + modifier = + Modifier + .fillMaxSize() + .offset(y = 4.dp) + .background( + color = borderColor, + shape = RoundedCornerShape(100), + ), + ) + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background( + color = backgroundColor, + shape = RoundedCornerShape(100), + ).then( + if (hasBorder) { + Modifier.border( + width = 1.6.dp, + color = borderColor, + shape = RoundedCornerShape(100), + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + AppText( + style = textStyle, + color = textColor, + text = text, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun AppRoundButtonPreview() { + TwixTheme { + Column { + AppRoundButton( + modifier = + Modifier + .width(330.dp) + .height(68.dp), + text = "버튼임니다", + textColor = GrayColor.C500, + backgroundColor = CommonColor.White, + ) + Spacer(modifier = Modifier.height(10.dp)) + AppRoundButton( + modifier = + Modifier + .width(330.dp) + .height(68.dp), + text = "버튼임니다", + textColor = CommonColor.White, + backgroundColor = GrayColor.C500, + hasBorder = false, + ) + Spacer(modifier = Modifier.height(10.dp)) + } + } +} diff --git a/core/ui/src/main/java/com/twix/ui/base/ObserveAsEvents.kt b/core/ui/src/main/java/com/twix/ui/base/ObserveAsEvents.kt new file mode 100644 index 00000000..33a1c601 --- /dev/null +++ b/core/ui/src/main/java/com/twix/ui/base/ObserveAsEvents.kt @@ -0,0 +1,33 @@ +package com.twix.ui.base + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow + +/** + * [Flow]를 통해 전달되는 일회성 이벤트(Side Effect)를 Lifecycle에 안전하게 관찰하기 위한 Composable 함수입니다. + * * 주로 ViewModel의 Channel이나 SharedFlow를 통해 전달되는 네비게이션, 스낵바 표시 등의 + * UI 이벤트를 처리할 때 사용됩니다. + * + * @param T 이벤트의 타입. + * @param flow 관찰할 [Flow] 인스턴스 (예: ViewModel의 events). + * @param onEvent 이벤트가 발생했을 때 실행할 suspend 콜백 함수. + * + */ +@Composable +fun ObserveAsEvents( + event: Flow, + onEvent: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(event, lifecycleOwner) { + // lifecycleOwner가 STARTED 상태일 때만 수집을 시작 + // STOPPED 상태가 되면 수집 코루틴을 자동으로 취소 + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + event.collect(onEvent) + } + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt index 3dd4d8d7..70f0ec5e 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationScreen.kt @@ -3,6 +3,7 @@ package com.twix.task_certification import android.Manifest import android.content.pm.PackageManager 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.Column @@ -26,6 +27,9 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.toast.ToastManager +import com.twix.designsystem.components.toast.model.ToastData +import com.twix.designsystem.components.toast.model.ToastType import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.AppTextStyle @@ -35,13 +39,16 @@ import com.twix.task_certification.component.CameraPreviewBox import com.twix.task_certification.component.TaskCertificationTopBar import com.twix.task_certification.model.CameraPreview import com.twix.task_certification.model.TaskCertificationIntent +import com.twix.task_certification.model.TaskCertificationSideEffect import com.twix.task_certification.model.TaskCertificationUiState +import com.twix.ui.base.ObserveAsEvents import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun TaskCertificationRoute( + toastManager: ToastManager = koinInject(), camera: Camera = koinInject(), viewModel: TaskCertificationViewModel = koinViewModel(), navigateToBack: () -> Unit, @@ -69,6 +76,11 @@ fun TaskCertificationRoute( hasPermission = granted } + val pickMedia = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + viewModel.dispatch(TaskCertificationIntent.PickPicture(uri)) + } + LaunchedEffect(Unit) { if (!hasPermission) { permissionLauncher.launch(Manifest.permission.CAMERA) @@ -93,6 +105,20 @@ fun TaskCertificationRoute( } } + val imageCaptureFailMessage = stringResource(R.string.task_certification_image_capture_fail) + ObserveAsEvents(viewModel.sideEffect) { event -> + when (event) { + TaskCertificationSideEffect.ShowImageCaptureFailToast -> { + toastManager.tryShow( + ToastData( + message = imageCaptureFailMessage, + type = ToastType.ERROR, + ), + ) + } + } + } + TaskCertificationScreen( uiState = uiState, cameraPreview = cameraPreview, @@ -105,6 +131,8 @@ fun TaskCertificationRoute( .takePicture() .onSuccess { viewModel.dispatch(TaskCertificationIntent.TakePicture(it)) + }.onFailure { + viewModel.dispatch(TaskCertificationIntent.TakePicture(null)) } } }, @@ -114,6 +142,13 @@ fun TaskCertificationRoute( onClickFlash = { viewModel.dispatch(TaskCertificationIntent.ToggleTorch) }, + onClickGallery = { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onClickRefresh = { + viewModel.dispatch(TaskCertificationIntent.RetakePicture) + }, + onClickUpload = { }, ) } @@ -125,6 +160,9 @@ private fun TaskCertificationScreen( onCaptureClick: () -> Unit, onToggleCameraClick: () -> Unit, onClickFlash: () -> Unit, + onClickGallery: () -> Unit, + onClickRefresh: () -> Unit, + onClickUpload: () -> Unit, ) { Column( Modifier @@ -157,8 +195,12 @@ private fun TaskCertificationScreen( Spacer(modifier = Modifier.height(52.dp)) CameraControlBar( + capture = uiState.capture, onCaptureClick = onCaptureClick, onToggleCameraClick = onToggleCameraClick, + onClickGallery = onClickGallery, + onClickRefresh = onClickRefresh, + onClickUpload = onClickUpload, ) } } @@ -174,6 +216,9 @@ fun TaskCertificationScreenPreview() { onCaptureClick = {}, onToggleCameraClick = {}, onClickFlash = {}, + onClickGallery = {}, + onClickRefresh = {}, + onClickUpload = {}, ) } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt index 34c3981e..b1b2370c 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/TaskCertificationViewModel.kt @@ -18,6 +18,10 @@ class TaskCertificationViewModel : reducePicture(intent.uri) } + is TaskCertificationIntent.PickPicture -> { + reducePicture(intent.uri) + } + is TaskCertificationIntent.ToggleLens -> { reduceLens() } @@ -25,6 +29,10 @@ class TaskCertificationViewModel : is TaskCertificationIntent.ToggleTorch -> { reduceTorch() } + + is TaskCertificationIntent.RetakePicture -> { + setupRetake() + } } } @@ -47,4 +55,8 @@ class TaskCertificationViewModel : private fun reduceTorch() { reduce { toggleTorch() } } + + private fun setupRetake() { + reduce { removePicture() } + } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt index 7f302816..a3d1a46f 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraControlBar.kt @@ -2,48 +2,93 @@ package com.twix.task_certification.component import androidx.compose.foundation.Image 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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.twix.designsystem.components.button.AppRoundButton +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle import com.twix.task_certification.R +import com.twix.task_certification.model.CaptureStatus import com.twix.ui.extension.noRippleClickable @Composable internal fun CameraControlBar( + capture: CaptureStatus, + onCaptureClick: () -> Unit, + onToggleCameraClick: () -> Unit, + onClickGallery: () -> Unit, + onClickRefresh: () -> Unit, + onClickUpload: () -> Unit, modifier: Modifier = Modifier, +) { + when (capture) { + CaptureStatus.NotCaptured -> { + ImageNotCapturedBar( + onCaptureClick = onCaptureClick, + onToggleCameraClick = onToggleCameraClick, + onClickGallery = onClickGallery, + modifier = modifier, + ) + } + + is CaptureStatus.Captured -> { + ImageCapturedBar( + onClickRefresh = onClickRefresh, + onClickUpload = onClickUpload, + modifier = modifier, + ) + } + } +} + +@Composable +private fun ImageNotCapturedBar( onCaptureClick: () -> Unit, onToggleCameraClick: () -> Unit, + onClickGallery: () -> Unit, + modifier: Modifier = Modifier, ) { + var enabled by remember { mutableStateOf(true) } + Row( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .padding(horizontal = 41.dp), + .padding(horizontal = 45.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Image( - painter = painterResource(R.drawable.btn), + imageVector = ImageVector.vectorResource(R.drawable.ic_gallery), contentDescription = null, - contentScale = ContentScale.Crop, modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(3.dp)), + .size(56.dp) + .noRippleClickable(enabled = enabled, onClick = onClickGallery), ) Image( @@ -51,23 +96,90 @@ internal fun CameraControlBar( contentDescription = null, modifier = Modifier - .noRippleClickable(onClick = onCaptureClick), + .noRippleClickable(enabled = enabled) { + enabled = false + onCaptureClick() + }, ) + Image( imageVector = ImageVector.vectorResource(R.drawable.ic_camera_toggle), contentDescription = null, modifier = Modifier - .noRippleClickable(onClick = onToggleCameraClick), + .size(56.dp) + .noRippleClickable(enabled = enabled, onClick = onToggleCameraClick), + ) + } +} + +@Composable +private fun ImageCapturedBar( + onClickRefresh: () -> Unit, + onClickUpload: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 58.dp), + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_camera_retake), + contentDescription = null, + modifier = + Modifier + .size(52.dp) + .align(Alignment.CenterStart) + .noRippleClickable(onClick = onClickRefresh), ) + + Row( + modifier = + Modifier + .align(Alignment.Center), + ) { + Spacer(modifier = Modifier.width(12.dp)) + + AppRoundButton( + borderColor = CommonColor.White, + backgroundColor = GrayColor.C500, + text = stringResource(R.string.task_certification_image_upload), + textStyle = AppTextStyle.T2, + textColor = CommonColor.White, + modifier = + Modifier + .width(150.dp) + .height(74.dp) + .noRippleClickable(onClick = onClickUpload), + ) + } } } -@Preview(showBackground = true) +@Preview(showBackground = true, backgroundColor = 0xFF000000) @Composable private fun CameraControlBarPreview() { - CameraControlBar( - onCaptureClick = {}, - onToggleCameraClick = {}, - ) + TwixTheme { + Column { + CameraControlBar( + capture = CaptureStatus.NotCaptured, + onCaptureClick = {}, + onToggleCameraClick = {}, + onClickGallery = {}, + onClickRefresh = {}, + onClickUpload = {}, + ) + + CameraControlBar( + capture = CaptureStatus.Captured("".toUri()), + onCaptureClick = {}, + onToggleCameraClick = {}, + onClickGallery = {}, + onClickRefresh = {}, + onClickUpload = {}, + ) + } + } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt index 8aca0940..d4d2be75 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationIntent.kt @@ -8,7 +8,13 @@ sealed interface TaskCertificationIntent : Intent { val uri: Uri?, ) : TaskCertificationIntent + data class PickPicture( + val uri: Uri?, + ) : TaskCertificationIntent + data object ToggleLens : TaskCertificationIntent data object ToggleTorch : TaskCertificationIntent + + data object RetakePicture : TaskCertificationIntent } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt index a16f5a18..d3770083 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/model/TaskCertificationUiState.kt @@ -33,9 +33,13 @@ data class TaskCertificationUiState( return copy(torch = newFlashMode) } + fun updateCapturedImage(uri: Uri) = copy(capture = CaptureStatus.Captured(uri)) + fun updatePicture(uri: Uri): TaskCertificationUiState = copy( capture = CaptureStatus.Captured(uri), torch = TorchStatus.Off, ) + + fun removePicture(): TaskCertificationUiState = copy(capture = CaptureStatus.NotCaptured) } diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_retake.xml b/feature/task-certification/src/main/res/drawable/ic_camera_retake.xml new file mode 100644 index 00000000..2b9675f4 --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_camera_retake.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/task-certification/src/main/res/drawable/ic_gallery.xml b/feature/task-certification/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 00000000..e57d49ca --- /dev/null +++ b/feature/task-certification/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/feature/task-certification/src/main/res/values/strings.xml b/feature/task-certification/src/main/res/values/strings.xml index 88875eed..4f8a8edb 100644 --- a/feature/task-certification/src/main/res/values/strings.xml +++ b/feature/task-certification/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ 인증샷 찍기 + 이미지 캡처에 실패했습니다. 다시 시도해 주세요. + 이미지를 불러오는 데 실패했습니다. 다시 시도해 주세요. + 업로드