diff --git a/app/src/main/java/com/yapp/twix/main/MainActivity.kt b/app/src/main/java/com/yapp/twix/main/MainActivity.kt index 4de9cb2..733ea86 100644 --- a/app/src/main/java/com/yapp/twix/main/MainActivity.kt +++ b/app/src/main/java/com/yapp/twix/main/MainActivity.kt @@ -3,11 +3,13 @@ package com.yapp.twix.main import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat import com.twix.designsystem.theme.TwixTheme import com.twix.navigation.AppNavHost import com.twix.ui.toast.ToastHost @@ -17,15 +19,16 @@ import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + enableEdgeToEdge() setContent { val toastManager by inject() + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = true TwixTheme { Box( modifier = Modifier - .safeContentPadding() .fillMaxSize(), ) { AppNavHost() diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentBox.kt b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentBox.kt new file mode 100644 index 0000000..f15d9d2 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentBox.kt @@ -0,0 +1,58 @@ +package com.twix.designsystem.components.comment + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.comment.model.CommentUiModel +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.GrayColor +import com.twix.domain.model.enums.AppTextStyle + +@Composable +fun CommentBox( + uiModel: CommentUiModel, + onCommentChanged: (TextFieldValue) -> Unit, + onFocusChanged: (Boolean) -> Unit, + onGuideTextPositioned: (Rect) -> Unit, + onTextFieldPositioned: (Rect) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + if (uiModel.isFocused) { + AppText( + text = stringResource(R.string.comment_condition_guide), + style = AppTextStyle.B2, + color = GrayColor.C100, + modifier = + Modifier.onGloballyPositioned { + onGuideTextPositioned(it.boundsInRoot()) + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + CommentTextField( + uiModel = uiModel, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + onPositioned = onTextFieldPositioned, + modifier = + Modifier + .padding(bottom = 20.dp), + ) + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentCircle.kt b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentCircle.kt new file mode 100644 index 0000000..bcb9d2c --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentCircle.kt @@ -0,0 +1,61 @@ +package com.twix.designsystem.components.comment + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +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.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 +internal fun CommentCircle( + text: String, + showPlaceholder: Boolean, + showCursor: Boolean, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .size(64.dp) + .background(color = CommonColor.White, shape = CircleShape), + ) { + AppText( + text = text, + style = AppTextStyle.H1, + color = if (showPlaceholder) GrayColor.C200 else GrayColor.C500, + ) + + if (showCursor) CursorBar() + } +} + +@Composable +private fun CursorBar() { + Box( + modifier = + Modifier + .width(2.dp) + .height(28.dp) + .background(GrayColor.C500), + ) +} + +@Preview +@Composable +private fun CommentCirclePreview() { + TwixTheme { + CommentCircle(text = "1", showPlaceholder = false, showCursor = false) + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentTextField.kt b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentTextField.kt new file mode 100644 index 0000000..44d3a24 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentTextField.kt @@ -0,0 +1,172 @@ +package com.twix.designsystem.components.comment + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.comment.model.CommentUiModel +import com.twix.designsystem.keyboard.Keyboard +import com.twix.designsystem.keyboard.keyboardAsState +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import kotlinx.coroutines.android.awaitFrame + +val CIRCLE_PADDING_START: Dp = 50.dp +val CIRCLE_SIZE: Dp = 64.dp +private val CIRCLE_GAP: Dp = CIRCLE_PADDING_START - CIRCLE_SIZE + +@Composable +fun CommentTextField( + uiModel: CommentUiModel, + onCommentChanged: (TextFieldValue) -> Unit, + onFocusChanged: (Boolean) -> Unit, + onPositioned: (Rect) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val interactionSource = remember { MutableInteractionSource() } + val placeholder = stringResource(R.string.comment_text_field_placeholder) + + val keyboardVisibility by keyboardAsState() + + LaunchedEffect(keyboardVisibility) { + when (keyboardVisibility) { + Keyboard.Opened -> Unit + Keyboard.Closed -> { + focusManager.clearFocus() + onFocusChanged(false) + } + } + } + + LaunchedEffect(uiModel.isFocused) { + if (uiModel.isFocused) { + focusRequester.requestFocus() + awaitFrame() + keyboardController?.show() + } else { + keyboardController?.hide() + } + } + + Box( + modifier = + modifier + .onGloballyPositioned { coordinates -> + onPositioned(coordinates.boundsInRoot()) + }.clickable( + // TODO : noClickableRipple 로 수정 + interactionSource = interactionSource, + indication = null, + onClick = { focusRequester.requestFocus() }, + ), + ) { + BasicTextField( + value = uiModel.comment, + onValueChange = { newValue -> onCommentChanged(newValue) }, + modifier = + Modifier + .alpha(0f) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + onFocusChanged(focusState.isFocused) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + singleLine = true, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(CIRCLE_GAP), + modifier = + Modifier.drawWithCache { + val radius = size.height / 2 + val paddingStart = CIRCLE_PADDING_START.toPx() + + onDrawBehind { + repeat(CommentUiModel.COMMENT_COUNT) { index -> + val cx = radius + index * paddingStart + + drawCircle( + color = GrayColor.C500, + radius = radius, + center = Offset(cx, radius), + style = Stroke(2.dp.toPx()), + ) + } + } + }, + ) { + repeat(CommentUiModel.COMMENT_COUNT) { index -> + val char = + if (uiModel.hidePlaceholder) { + uiModel.comment.text + .getOrNull(index) + ?.toString() + } else { + placeholder.getOrNull(index)?.toString() + }.orEmpty() + + CommentCircle( + text = char, + showPlaceholder = !uiModel.hidePlaceholder, + showCursor = uiModel.showCursor(index), + modifier = + Modifier.clickable( + indication = null, + interactionSource = interactionSource, + ) { + focusRequester.requestFocus() + onCommentChanged(uiModel.comment.copy(selection = TextRange(uiModel.comment.text.length))) + }, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CommentTextFieldPreview() { + TwixTheme { + var text by remember { mutableStateOf(TextFieldValue("")) } + var isFocused by remember { mutableStateOf(false) } + CommentTextField( + uiModel = CommentUiModel(text, isFocused), + onCommentChanged = { text = it }, + onFocusChanged = { isFocused = it }, + onPositioned = {}, + ) + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt b/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt new file mode 100644 index 0000000..8690672 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/comment/model/CommentUiModel.kt @@ -0,0 +1,59 @@ +package com.twix.designsystem.components.comment.model + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.input.TextFieldValue + +@Immutable +data class CommentUiModel( + val comment: TextFieldValue = TextFieldValue(""), + val isFocused: Boolean = false, +) { + val isEmpty: Boolean + get() = comment.text.isEmpty() + + val hasMaxCommentLength: Boolean + get() = comment.text.length == COMMENT_COUNT + + val canUpload: Boolean + get() = + comment.text.isEmpty() || + comment.text.isNotEmpty() && + hasMaxCommentLength + + fun updateComment(newComment: TextFieldValue): CommentUiModel { + if (comment.text.length > COMMENT_COUNT) return this + return copy(comment = newComment) + } + + fun updateFocus(isFocused: Boolean) = copy(isFocused = isFocused) + + /** + * 특정 index 위치에 커서를 표시할지 여부를 반환한다. + * + * 표시 조건 + * 1. 포커스 상태일 것 + * 2. 현재 selection 시작 위치가 해당 index 일 것 + * 3. 해당 위치에 문자가 없을 것 (빈 칸) + * + * @param index 확인할 문자 위치 + */ + fun showCursor(index: Int): Boolean { + val isCharEmpty = comment.text.getOrNull(index) == null + return isFocused && comment.selection.start == index && isCharEmpty + } + + /** + * 플레이스홀더 표시 여부. + * + * 표시 조건 + * - 포커스 중이 아니거나 + * - 텍스트가 하나라도 존재하지 않으면 + * + */ + val hidePlaceholder: Boolean + get() = isFocused || comment.text.isNotEmpty() + + companion object { + const val COMMENT_COUNT = 5 + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/keyboard/KeyBoardAsState.kt b/core/design-system/src/main/java/com/twix/designsystem/keyboard/KeyBoardAsState.kt new file mode 100644 index 0000000..56a54a9 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/keyboard/KeyBoardAsState.kt @@ -0,0 +1,33 @@ +package com.twix.designsystem.keyboard + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalDensity + +// TODO : core:ui 모듈로 이동 + +/** + * 현재 소프트 키보드(IME)의 열림/닫힘 상태를 [Keyboard] 값으로 제공하는 Composable 유틸 함수. + * + * [WindowInsets.ime] 의 bottom inset 값을 사용하여 + * 키보드가 화면을 밀어 올리고 있는지 여부를 감지합니다. + * + * - inset > 0 → [Keyboard.Opened] + * - inset == 0 → [Keyboard.Closed] + * + * @return 현재 키보드 상태를 나타내는 [State]<[Keyboard]> + */ +@Composable +fun keyboardAsState(): State { + val density = LocalDensity.current + val keyboard = + if (WindowInsets.ime.getBottom(density) > 0) { + Keyboard.Opened + } else { + Keyboard.Closed + } + return rememberUpdatedState(keyboard) +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/keyboard/Keyboard.kt b/core/design-system/src/main/java/com/twix/designsystem/keyboard/Keyboard.kt new file mode 100644 index 0000000..414a38e --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/keyboard/Keyboard.kt @@ -0,0 +1,6 @@ +package com.twix.designsystem.keyboard + +enum class Keyboard { + Opened, + Closed, +} diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml index da500dd..b06dec3 100644 --- a/core/design-system/src/main/res/values/strings.xml +++ b/core/design-system/src/main/res/values/strings.xml @@ -17,5 +17,8 @@ 오늘 우리 목표 첫 목표를 세워볼까요? - - \ No newline at end of file + + + 코멘트추가 + 5글자로 코멘트를 남길 수 있어요 + 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 7e00b6e..0ce445b 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 @@ -5,11 +5,16 @@ import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Canvas 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.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -20,31 +25,51 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.coroutineScope +import com.twix.designsystem.components.comment.CIRCLE_PADDING_START +import com.twix.designsystem.components.comment.CIRCLE_SIZE +import com.twix.designsystem.components.comment.model.CommentUiModel import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.DimmedColor import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.AppTextStyle import com.twix.task_certification.camera.Camera import com.twix.task_certification.component.CameraControlBar import com.twix.task_certification.component.CameraPreviewBox +import com.twix.task_certification.component.CommentErrorText 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 com.twix.ui.extension.noRippleClickable import com.twix.ui.toast.ToastManager import com.twix.ui.toast.model.ToastData import com.twix.ui.toast.model.ToastType import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import com.twix.designsystem.R as DesR @Composable fun TaskCertificationRoute( @@ -58,7 +83,7 @@ fun TaskCertificationRoute( val lifecycleOwner = LocalLifecycleOwner.current val coroutineScope = rememberCoroutineScope() - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current var hasPermission by remember { mutableStateOf( @@ -148,7 +173,15 @@ fun TaskCertificationRoute( onClickRefresh = { viewModel.dispatch(TaskCertificationIntent.RetakePicture) }, - onClickUpload = { }, + onCommentChanged = { + viewModel.dispatch(TaskCertificationIntent.UpdateComment(it)) + }, + onFocusChanged = { + viewModel.dispatch(TaskCertificationIntent.CommentFocusChanged(it)) + }, + onClickUpload = { + viewModel.dispatch(TaskCertificationIntent.Upload) + }, ) } @@ -163,11 +196,19 @@ private fun TaskCertificationScreen( onClickGallery: () -> Unit, onClickRefresh: () -> Unit, onClickUpload: () -> Unit, + onCommentChanged: (TextFieldValue) -> Unit, + onFocusChanged: (Boolean) -> Unit, ) { + val focusManager = LocalFocusManager.current + var textFieldRect by remember { mutableStateOf(Rect.Zero) } + var guideTextRect by remember { mutableStateOf(Rect.Zero) } + Column( Modifier .fillMaxSize() - .background(GrayColor.C500), + .background(GrayColor.C500) + .noRippleClickable { focusManager.clearFocus() } + .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { TaskCertificationTopBar( @@ -176,20 +217,31 @@ private fun TaskCertificationScreen( Spacer(modifier = Modifier.height(24.26.dp)) - AppText( - text = stringResource(R.string.task_certification_title), - style = AppTextStyle.H2, - color = GrayColor.C100, - ) + AnimatedContent(targetState = uiState.showCommentError) { isError -> + if (isError) { + CommentErrorText() + } else { + AppText( + text = stringResource(R.string.task_certification_title), + style = AppTextStyle.H2, + color = GrayColor.C100, + ) + } + } Spacer(modifier = Modifier.height(40.dp)) CameraPreviewBox( showTorch = uiState.showTorch, capture = uiState.capture, + commentUiModel = uiState.commentUiModel, previewRequest = cameraPreview, torch = uiState.torch, - onClickFlash = { onClickFlash() }, + onClickFlash = onClickFlash, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + onGuideTextPositioned = { guideTextRect = it }, + onTextFieldPositioned = { textFieldRect = it }, ) Spacer(modifier = Modifier.height(52.dp)) @@ -203,6 +255,65 @@ private fun TaskCertificationScreen( onClickUpload = onClickUpload, ) } + + DimmedScreen( + isFocused = uiState.commentUiModel.isFocused, + textFieldRect = textFieldRect, + guideTextRect = guideTextRect, + focusManager = focusManager, + ) +} + +@Composable +fun DimmedScreen( + isFocused: Boolean, + textFieldRect: Rect, + guideTextRect: Rect, + focusManager: FocusManager, +) { + if (isFocused && textFieldRect != Rect.Zero) { + val density = LocalDensity.current + val circleSizePx = with(density) { CIRCLE_SIZE.toPx() } + val radiusPx = circleSizePx / 2 + val paddingStartPx = with(density) { CIRCLE_PADDING_START.toPx() } + + Canvas( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .noRippleClickable { focusManager.clearFocus() }, + ) { + drawRect(color = DimmedColor.D070) + + repeat(CommentUiModel.COMMENT_COUNT) { index -> + val cx = textFieldRect.left + radiusPx + (index * paddingStartPx) + val cy = textFieldRect.top + radiusPx + + drawCircle( + color = Color.Transparent, + radius = radiusPx, + center = Offset(cx, cy), + blendMode = BlendMode.Clear, + ) + } + } + + if (guideTextRect != Rect.Zero) { + Box( + modifier = + Modifier.offset { + IntOffset(guideTextRect.left.toInt(), guideTextRect.top.toInt()) + }, + ) { + AppText( + text = stringResource(DesR.string.comment_condition_guide), + style = AppTextStyle.B2, + color = GrayColor.C100, + ) + } + } + } } @Preview @@ -219,6 +330,8 @@ fun TaskCertificationScreenPreview() { onClickGallery = {}, onClickRefresh = {}, onClickUpload = {}, + onCommentChanged = {}, + onFocusChanged = {}, ) } } 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 b1b2370..d62830b 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 @@ -5,7 +5,9 @@ import androidx.lifecycle.viewModelScope import com.twix.task_certification.model.TaskCertificationIntent import com.twix.task_certification.model.TaskCertificationSideEffect import com.twix.task_certification.model.TaskCertificationUiState +import com.twix.task_certification.model.TorchStatus import com.twix.ui.base.BaseViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class TaskCertificationViewModel : @@ -15,11 +17,11 @@ class TaskCertificationViewModel : override suspend fun handleIntent(intent: TaskCertificationIntent) { when (intent) { is TaskCertificationIntent.TakePicture -> { - reducePicture(intent.uri) + takePicture(intent.uri) } is TaskCertificationIntent.PickPicture -> { - reducePicture(intent.uri) + pickPicture(intent.uri) } is TaskCertificationIntent.ToggleLens -> { @@ -33,18 +35,41 @@ class TaskCertificationViewModel : is TaskCertificationIntent.RetakePicture -> { setupRetake() } + + is TaskCertificationIntent.UpdateComment -> { + updateComment(intent) + } + + is TaskCertificationIntent.CommentFocusChanged -> { + updateCommentFocus(intent.isFocused) + } + + is TaskCertificationIntent.Upload -> { + upload() + } } } - private fun reducePicture(uri: Uri?) { - uri?.let { - reduce { updatePicture(uri) } - } ?: run { onFailureCapture() } + private fun takePicture(uri: Uri?) { + uri?.let { reducePicture(it) } ?: viewModelScope.launch { + emitSideEffect( + TaskCertificationSideEffect.ShowImageCaptureFailToast, + ) + } + } + + private fun pickPicture(uri: Uri?) { + uri?.let { reducePicture(uri) } } - private fun onFailureCapture() { - viewModelScope.launch { - emitSideEffect(TaskCertificationSideEffect.ShowImageCaptureFailToast) + private fun reducePicture(uri: Uri) { + reduce { updatePicture(uri) } + if (uiState.value.torch == TorchStatus.On) { + reduce { toggleTorch() } + } + + if (uiState.value.hasMaxCommentLength.not()) { + updateCommentFocus(true) } } @@ -59,4 +84,28 @@ class TaskCertificationViewModel : private fun setupRetake() { reduce { removePicture() } } + + private fun updateComment(intent: TaskCertificationIntent.UpdateComment) { + reduce { updateComment(intent.comment) } + } + + private fun updateCommentFocus(isFocused: Boolean) { + reduce { updateCommentFocus(isFocused) } + } + + private fun upload() { + if (uiState.value.commentUiModel.canUpload + .not() + ) { + viewModelScope.launch { + reduce { showCommentError() } + delay(ERROR_DISPLAY_DURATION_MS) + reduce { hideCommentError() } + } + } + } + + companion object { + private const val ERROR_DISPLAY_DURATION_MS = 1500L + } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt index d6ae935..bac04ef 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/component/CameraPreviewBox.kt @@ -4,20 +4,24 @@ import androidx.camera.compose.CameraXViewfinder import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.twix.designsystem.components.comment.CommentBox +import com.twix.designsystem.components.comment.model.CommentUiModel import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme import com.twix.task_certification.R @@ -28,19 +32,23 @@ import com.twix.ui.extension.noRippleClickable @Composable fun CameraPreviewBox( + commentUiModel: CommentUiModel, showTorch: Boolean, capture: CaptureStatus, previewRequest: CameraPreview?, torch: TorchStatus, onClickFlash: () -> Unit, + onCommentChanged: (TextFieldValue) -> Unit, + onFocusChanged: (Boolean) -> Unit, + onGuideTextPositioned: (Rect) -> Unit, + onTextFieldPositioned: (Rect) -> Unit, modifier: Modifier = Modifier, ) { Box( modifier = modifier - .fillMaxWidth() + .size(375.66.dp) .padding(horizontal = 5.dp) - .aspectRatio(1f) .border( color = GrayColor.C400, width = 2.dp, @@ -52,6 +60,15 @@ fun CameraPreviewBox( if (showTorch) { TorchIcon(torch, onClickFlash) } + + CommentBox( + uiModel = commentUiModel, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + onGuideTextPositioned = onGuideTextPositioned, + onTextFieldPositioned = onTextFieldPositioned, + modifier = Modifier.align(Alignment.BottomCenter), + ) } } @@ -107,11 +124,16 @@ private fun TorchIcon( fun CameraPreviewBoxNotCapturedPreview() { TwixTheme { CameraPreviewBox( + commentUiModel = CommentUiModel(isFocused = true), capture = CaptureStatus.NotCaptured, showTorch = true, torch = TorchStatus.Off, previewRequest = null, onClickFlash = {}, + onCommentChanged = {}, + onFocusChanged = {}, + onGuideTextPositioned = {}, + onTextFieldPositioned = {}, ) } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/component/CommentErrorText.kt b/feature/task-certification/src/main/java/com/twix/task_certification/component/CommentErrorText.kt new file mode 100644 index 0000000..d0a2b49 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/component/CommentErrorText.kt @@ -0,0 +1,47 @@ +package com.twix.task_certification.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +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.draw.clip +import androidx.compose.ui.res.stringResource +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 +import com.twix.task_certification.R + +@Composable +fun CommentErrorText(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .width(224.dp) + .height(56.dp) + .background(color = GrayColor.C400, shape = RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)), + ) { + AppText( + text = stringResource(R.string.comment_error_message), + style = AppTextStyle.B1, + color = CommonColor.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Preview +@Composable +fun CommentErrorTextPreview() { + TwixTheme { + CommentErrorText() + } +} 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 d4d2be7..6e6d8fe 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 @@ -1,6 +1,7 @@ package com.twix.task_certification.model import android.net.Uri +import androidx.compose.ui.text.input.TextFieldValue import com.twix.ui.base.Intent sealed interface TaskCertificationIntent : Intent { @@ -17,4 +18,14 @@ sealed interface TaskCertificationIntent : Intent { data object ToggleTorch : TaskCertificationIntent data object RetakePicture : TaskCertificationIntent + + data class UpdateComment( + val comment: TextFieldValue, + ) : TaskCertificationIntent + + data class CommentFocusChanged( + val isFocused: Boolean, + ) : TaskCertificationIntent + + data object Upload : 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 d377008..3b1d278 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 @@ -3,6 +3,8 @@ package com.twix.task_certification.model import android.net.Uri import androidx.camera.core.CameraSelector import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.input.TextFieldValue +import com.twix.designsystem.components.comment.model.CommentUiModel import com.twix.ui.base.State @Immutable @@ -11,7 +13,12 @@ data class TaskCertificationUiState( val torch: TorchStatus = TorchStatus.Off, val lens: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, val preview: CameraPreview? = null, + val commentUiModel: CommentUiModel = CommentUiModel(), + val showCommentError: Boolean = false, ) : State { + val hasMaxCommentLength: Boolean + get() = commentUiModel.hasMaxCommentLength + val showTorch: Boolean get() = capture is CaptureStatus.NotCaptured && lens == CameraSelector.DEFAULT_BACK_CAMERA @@ -33,8 +40,6 @@ 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), @@ -42,4 +47,12 @@ data class TaskCertificationUiState( ) fun removePicture(): TaskCertificationUiState = copy(capture = CaptureStatus.NotCaptured) + + fun updateComment(comment: TextFieldValue) = copy(commentUiModel = commentUiModel.updateComment(comment)) + + fun updateCommentFocus(isFocused: Boolean) = copy(commentUiModel = commentUiModel.updateFocus(isFocused)) + + fun showCommentError() = copy(showCommentError = true) + + fun hideCommentError() = copy(showCommentError = false) } diff --git a/feature/task-certification/src/main/res/drawable/btn.png b/feature/task-certification/src/main/res/drawable/btn.png deleted file mode 100644 index 0b1fe83..0000000 Binary files a/feature/task-certification/src/main/res/drawable/btn.png and /dev/null differ diff --git a/feature/task-certification/src/main/res/values/strings.xml b/feature/task-certification/src/main/res/values/strings.xml index 4f8a8ed..2729d81 100644 --- a/feature/task-certification/src/main/res/values/strings.xml +++ b/feature/task-certification/src/main/res/values/strings.xml @@ -3,4 +3,5 @@ 이미지 캡처에 실패했습니다. 다시 시도해 주세요. 이미지를 불러오는 데 실패했습니다. 다시 시도해 주세요. 업로드 + 코멘트는 5글자로 입력해주세요!