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 index d2df0a12..b2622c9a 100644 --- 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 @@ -26,9 +26,9 @@ import com.twix.domain.model.enums.AppTextStyle fun AppRoundButton( text: String, textColor: Color, + backgroundColor: Color, modifier: Modifier = Modifier, textStyle: AppTextStyle = AppTextStyle.T2, - backgroundColor: Color, borderColor: Color = GrayColor.C500, hasBorder: Boolean = true, ) { diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentAnchorFrame.kt b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentAnchorFrame.kt new file mode 100644 index 00000000..cce5a1ea --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/comment/CommentAnchorFrame.kt @@ -0,0 +1,105 @@ +package com.twix.designsystem.components.comment + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.twix.designsystem.components.comment.model.CommentUiModel +import com.twix.designsystem.theme.DimmedColor +import com.twix.ui.extension.noRippleClickable + +/** + * 특정 UI 요소(Anchor) 하단에 [CommentBox]를 배치하고, 키보드 활성화 상태에 따라 위치를 동적으로 조정하는 프레임 컴포저블 + * + * 이 컴포저블은 평상시에는 [anchorBottom] 좌표를 기준으로 배치 + * 키보드가 올라와 코맨트창이 가려질 경우 키보드 바로 위로 위치를 자동으로 이동 + * + * @param uiModel 댓글창의 상태(텍스트, 포커스 상태)를 담고 있는 데이터 모델 + * @param anchorBottom 댓글창 배치의 기준이 되는 상위 요소의 바닥(Bottom) Y 좌표 (px 단위) + * @param onCommentChanged 댓글 내용이 변경될 때 호출되는 콜백 + * @param onFocusChanged 댓글창의 포커스 상태가 변경될 때 호출되는 콜백 (포커스 시 배경 딤 처리 등에 사용) + * @param modifier 레이아웃 수정을 위한 [Modifier] + */ +@Composable +fun CommentAnchorFrame( + uiModel: CommentUiModel, + anchorBottom: Float, + onCommentChanged: (String) -> Unit, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + if (anchorBottom == 0f) return + + val density = LocalDensity.current + val focusManager = LocalFocusManager.current + val imeBottom = WindowInsets.ime.getBottom(density) + val paddingBottom = with(density) { 24.dp.toPx() } + + var commentBoxHeight by remember { mutableFloatStateOf(0f) } + val defaultY = anchorBottom - commentBoxHeight - paddingBottom + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val screenHeight = constraints.maxHeight.toFloat() + val keyboardTop = screenHeight - imeBottom + + AnimatedVisibility( + visible = uiModel.isFocused, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(DimmedColor.D070) + .noRippleClickable { focusManager.clearFocus() }, + ) + } + CommentBox( + uiModel = uiModel, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + onHeightMeasured = { height -> + if (commentBoxHeight != height) commentBoxHeight = height + }, + modifier = + Modifier + .fillMaxWidth() + .offset { + /** + * 키보드가 활성화되었고, 댓글창이 키보드 위치보다 아래에 있어 가려지는 경우 + * */ + val targetY = + if (imeBottom > 0 && (defaultY + commentBoxHeight) > keyboardTop) { + /** + * 화면 전체 높이 - 키보드 높이 - 코멘트 높이 + * */ + (screenHeight - imeBottom - commentBoxHeight).toInt() + } else { + /** + * 키보드가 없거나, 댓글창이 키보드에 가려지지 않는 경우 + * */ + defaultY.toInt() + } + IntOffset(x = 0, y = targetY) + }, + ) + } +} 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 index 595136bd..1eaa61a6 100644 --- 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 @@ -3,10 +3,10 @@ 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.layout.onSizeChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.twix.designsystem.R @@ -20,11 +20,15 @@ fun CommentBox( uiModel: CommentUiModel, onCommentChanged: (String) -> Unit, onFocusChanged: (Boolean) -> Unit, + onHeightMeasured: (Float) -> Unit, modifier: Modifier = Modifier, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier, + modifier = + modifier.onSizeChanged { size -> + onHeightMeasured(size.height.toFloat()) + }, ) { AppText( text = if (uiModel.isFocused) stringResource(R.string.comment_condition_guide) else "", @@ -33,13 +37,11 @@ fun CommentBox( ) Spacer(modifier = Modifier.height(8.dp)) + CommentTextField( uiModel = uiModel, onCommitComment = onCommentChanged, onFocusChanged = onFocusChanged, - modifier = - Modifier - .padding(bottom = 20.dp), ) } } 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 index d76fdd50..8f3ca856 100644 --- 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 @@ -21,13 +21,12 @@ 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.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 @@ -50,13 +49,30 @@ fun CommentTextField( enabled: Boolean = true, onCommitComment: (String) -> Unit = {}, onFocusChanged: (Boolean) -> Unit = {}, - onPositioned: (Rect) -> Unit = {}, ) { val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } val keyboardState by keyboardAsState() - var internalValue by rememberSaveable(uiModel.comment) { mutableStateOf(uiModel.comment) } + var internalValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = uiModel.value, + selection = TextRange(uiModel.value.length), + ), + ) + } + + LaunchedEffect(uiModel.value) { + if (uiModel.value != internalValue.text) { + internalValue = + TextFieldValue( + text = uiModel.value, + selection = TextRange(uiModel.value.length), + ) + } + } + var isInitialized by remember { mutableStateOf(false) } LaunchedEffect(keyboardState) { @@ -66,7 +82,7 @@ fun CommentTextField( when (keyboardState) { Keyboard.Opened -> Unit Keyboard.Closed -> { - onCommitComment(internalValue.trim()) + onCommitComment(internalValue.text.trim()) focusManager.clearFocus() } } @@ -74,23 +90,28 @@ fun CommentTextField( } LaunchedEffect(uiModel.isFocused) { + /** + * 외부에서 포커스 상태를 제어하는 경우 + * e.g 사진 촬영 & 사진 선택 + * */ if (uiModel.isFocused) focusRequester.requestFocus() } Box( modifier = modifier - .onGloballyPositioned { coordinates -> - onPositioned(coordinates.boundsInRoot()) - }.noRippleClickable { + .noRippleClickable { focusRequester.requestFocus() }, ) { TextField( value = internalValue, onValueChange = { newValue -> - if (newValue.length <= CommentUiModel.COMMENT_COUNT) { - internalValue = newValue + if (newValue.text.length <= CommentUiModel.COMMENT_COUNT) { + internalValue = + newValue.copy( + selection = TextRange(newValue.text.length), + ) } }, enabled = enabled, @@ -130,16 +151,16 @@ fun CommentTextField( ) { repeat(CommentUiModel.COMMENT_COUNT) { index -> val char = - if (uiModel.isFocused || internalValue.isNotEmpty()) { - internalValue.getOrNull(index)?.toString() + if (uiModel.isFocused || internalValue.text.isNotEmpty()) { + internalValue.text.getOrNull(index)?.toString() } else { stringResource(R.string.comment_text_field_placeholder)[index].toString() }.orEmpty() CommentCircle( text = char, - showPlaceholder = !uiModel.isFocused && internalValue.isEmpty(), - showCursor = uiModel.isFocused && index == internalValue.length, + showPlaceholder = !uiModel.isFocused && internalValue.text.isEmpty(), + showCursor = uiModel.isFocused && index == internalValue.text.length, modifier = Modifier.noRippleClickable { focusRequester.requestFocus() @@ -159,7 +180,6 @@ private fun CommentTextFieldPreview() { CommentTextField( uiModel = CommentUiModel(text, isFocused), 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 index e44e5675..284c0bad 100644 --- 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 @@ -4,19 +4,19 @@ import androidx.compose.runtime.Immutable @Immutable data class CommentUiModel( - val comment: String = "", + val value: String = "", val isFocused: Boolean = false, ) { val hasMaxCommentLength: Boolean - get() = comment.length == COMMENT_COUNT + get() = value.length == COMMENT_COUNT val canUpload: Boolean get() = - comment.isEmpty() || - comment.isNotEmpty() && + value.isEmpty() || + value.isNotEmpty() && hasMaxCommentLength - fun updateComment(newComment: String): CommentUiModel = copy(comment = newComment) + fun updateComment(newComment: String): CommentUiModel = copy(value = newComment) fun updateFocus(isFocused: Boolean) = copy(isFocused = isFocused) diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/BackgroundCard.kt b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/BackgroundCard.kt similarity index 90% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/component/BackgroundCard.kt rename to core/design-system/src/main/java/com/twix/designsystem/components/photolog/BackgroundCard.kt index 284dcfb4..ab898f48 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/BackgroundCard.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/BackgroundCard.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.component +package com.twix.designsystem.components.photolog import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -16,15 +16,14 @@ 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 com.twix.designsystem.R import com.twix.designsystem.components.button.AppRoundButton 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 import com.twix.ui.extension.noRippleClickable -import com.twix.designsystem.R as DesR @Composable fun BackgroundCard( @@ -69,7 +68,7 @@ fun BackgroundCard( } Image( - imageVector = ImageVector.vectorResource(DesR.drawable.ic_keepi_sting), + imageVector = ImageVector.vectorResource(R.drawable.ic_keepi_sting), contentDescription = null, modifier = Modifier @@ -86,7 +85,7 @@ fun BackgroundCard( fun PreviewBackgroundCard() { TwixTheme { BackgroundCard( - buttonTitle = stringResource(R.string.task_certification_detail_partner_sting), + buttonTitle = stringResource(R.string.word_sting), uploadedAt = "2023.10.31 23:59", onClick = {}, isCertificated = true, diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/CertificatedCard.kt b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/CertificatedCard.kt similarity index 95% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/component/CertificatedCard.kt rename to core/design-system/src/main/java/com/twix/designsystem/components/photolog/CertificatedCard.kt index 51967e1e..9d2ee54a 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/CertificatedCard.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/CertificatedCard.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.component +package com.twix.designsystem.components.photolog import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -18,7 +18,7 @@ import com.twix.designsystem.components.comment.model.CommentUiModel import com.twix.designsystem.theme.TwixTheme @Composable -internal fun CertificatedCard( +fun CertificatedCard( imageUrl: String?, comment: String?, ) { diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/ForegroundCard.kt b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/ForegroundCard.kt similarity index 79% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/component/ForegroundCard.kt rename to core/design-system/src/main/java/com/twix/designsystem/components/photolog/ForegroundCard.kt index 4d4851a9..7bc42d08 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/ForegroundCard.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/ForegroundCard.kt @@ -1,19 +1,18 @@ -package com.twix.task_certification.detail.component +package com.twix.designsystem.components.photolog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.twix.designsystem.R 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.domain.model.enums.BetweenUs -import com.twix.task_certification.R -import com.twix.designsystem.R as DesR @Composable -internal fun ForegroundCard( +fun ForegroundCard( isCertificated: Boolean, nickName: String, imageUrl: String?, @@ -32,9 +31,11 @@ internal fun ForegroundCard( AppText( text = when (currentShow) { - BetweenUs.ME -> stringResource(DesR.string.keep_it_up) + BetweenUs.ME -> stringResource(R.string.keep_it_up) BetweenUs.PARTNER -> - stringResource(R.string.task_certification_detail_partner_not_task_certification).format(nickName) + stringResource(R.string.partner_not_task_certification).format( + nickName, + ) }, style = AppTextStyle.H2, color = GrayColor.C500, diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/PhotologCard.kt b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/PhotologCard.kt similarity index 87% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/component/PhotologCard.kt rename to core/design-system/src/main/java/com/twix/designsystem/components/photolog/PhotologCard.kt index fd1348fc..c89f068b 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/PhotologCard.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/photolog/PhotologCard.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.component +package com.twix.designsystem.components.photolog import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -16,13 +16,15 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme @Composable -internal fun PhotologCard( - background: Color, - borderColor: Color, +fun PhotologCard( modifier: Modifier = Modifier, + borderColor: Color = GrayColor.C500, + background: Color = CommonColor.White, rotation: Float = 0f, content: @Composable BoxScope.() -> Unit = {}, ) { diff --git a/feature/task-certification/src/main/res/drawable/ic_camara_rotate.xml b/core/design-system/src/main/res/drawable/ic_camara_rotate.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_camara_rotate.xml rename to core/design-system/src/main/res/drawable/ic_camara_rotate.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_retake.xml b/core/design-system/src/main/res/drawable/ic_camera_retake.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_camera_retake.xml rename to core/design-system/src/main/res/drawable/ic_camera_retake.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_shutter.xml b/core/design-system/src/main/res/drawable/ic_camera_shutter.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_camera_shutter.xml rename to core/design-system/src/main/res/drawable/ic_camera_shutter.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_toggle.xml b/core/design-system/src/main/res/drawable/ic_camera_toggle.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_camera_toggle.xml rename to core/design-system/src/main/res/drawable/ic_camera_toggle.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_torch_off.xml b/core/design-system/src/main/res/drawable/ic_camera_torch_off.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_camera_torch_off.xml rename to core/design-system/src/main/res/drawable/ic_camera_torch_off.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_camera_torch_on.xml b/core/design-system/src/main/res/drawable/ic_camera_torch_on.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_camera_torch_on.xml rename to core/design-system/src/main/res/drawable/ic_camera_torch_on.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_close_c100.xml b/core/design-system/src/main/res/drawable/ic_close_c100.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_close_c100.xml rename to core/design-system/src/main/res/drawable/ic_close_c100.xml diff --git a/feature/task-certification/src/main/res/drawable/ic_gallery.xml b/core/design-system/src/main/res/drawable/ic_gallery.xml similarity index 100% rename from feature/task-certification/src/main/res/drawable/ic_gallery.xml rename to core/design-system/src/main/res/drawable/ic_gallery.xml diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml index 6d338ec8..953d5f4f 100644 --- a/core/design-system/src/main/res/values/strings.xml +++ b/core/design-system/src/main/res/values/strings.xml @@ -15,10 +15,12 @@ 취소 삭제 수정 + 저장 설정 계정 정보 로그아웃 + 찌르기 매일 매주 @@ -48,6 +50,9 @@ 코멘트추가 5글자로 코멘트를 남길 수 있어요 + + %s님은\n아직… + 목표 직접 만들기 목표 수정하기 @@ -93,4 +98,26 @@ 정말 탈퇴하시겠어요? 커플 연결이 끊어집니다.\n데이터는 전부 삭제되며 복구가 불가능합니다. + + 업로드 + 이미지 캡처에 실패했습니다. 다시 시도해 주세요. + 이미지를 불러오는 데 실패했습니다. 다시 시도해 주세요. + 인증샷 찍기 + 인증샷 등록에 실패했습니다. + 이미지 변환에 실패했습니다. + 코멘트는 5글자로 입력해주세요! + + + 인증샷 조회에 실패했습니다. + 인증샷 수정에 실패했어요. + + + 다시 찍기 + 코멘트가 수정 되었어요. + 코멘트가 수정되지 않았어요. + 코멘트 수정에 실패했어요. + 찌르기 + 리액션 요청에 실패했어요. + + 인증샷 촬영을 위해서 카메라 권한이 필요해요. diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 9f20a596..a9a48887 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -2,8 +2,14 @@ plugins { alias(libs.plugins.twix.android.library) alias(libs.plugins.twix.android.compose) alias(libs.plugins.twix.koin) + alias(libs.plugins.serialization) + alias(libs.plugins.twix.kermit) } android { namespace = "com.twix.navigation" } + +dependencies { + implementation(libs.kotlinx.serialization.json) +} diff --git a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt index 4432d461..21b5bd01 100644 --- a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt +++ b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt @@ -1,5 +1,9 @@ package com.twix.navigation +import android.net.Uri +import com.twix.navigation.args.DetailNavArgs +import com.twix.navigation.args.EditorNavArgs +import kotlinx.serialization.json.Json import java.time.LocalDate /** @@ -44,19 +48,31 @@ sealed class NavRoutes( ) = "task_certification_detail/$goalId/$date/$betweenUs" } - object TaskCertificationRoute : NavRoutes("task_certification/{goalId}/{from}") { - const val ARG_GOAL_ID = "goalId" - const val ARG_FROM = "from" + object TaskCertificationRoute : NavRoutes("task_certification/{data}") { + const val ARG_DATA = "data" enum class From { HOME, DETAIL, + EDITOR, } - fun createRoute( - goalId: Long, - from: From, - ) = "task_certification/$goalId/${from.name}" + fun createRoute(data: DetailNavArgs): String { + val json = Json.encodeToString(data) + val encoded = Uri.encode(json) + return "task_certification/$encoded" + } + } + + object TaskCertificationEditorRoute : + NavRoutes("task_certification_editor/{data}") { + const val ARG_DATA = "data" + + fun createRoute(data: EditorNavArgs): String { + val json = Json.encodeToString(data) + val encoded = Uri.encode(json) + return "task_certification_editor/$encoded" + } } /** diff --git a/core/navigation/src/main/java/com/twix/navigation/args/TaskCertificationNavArgs.kt b/core/navigation/src/main/java/com/twix/navigation/args/TaskCertificationNavArgs.kt new file mode 100644 index 00000000..ec21a755 --- /dev/null +++ b/core/navigation/src/main/java/com/twix/navigation/args/TaskCertificationNavArgs.kt @@ -0,0 +1,24 @@ +package com.twix.navigation.args + +import com.twix.navigation.NavRoutes +import kotlinx.serialization.Serializable + +// TODO("인증샷 단일 조회 API 연동시 제거") +@Serializable +data class EditorNavArgs( + val goalId: Long, + val goalName: String, + val nickname: String, + val photologId: Long, + val imageUrl: String, + val comment: String?, +) + +@Serializable +data class DetailNavArgs( + val goalId: Long, + val from: NavRoutes.TaskCertificationRoute.From, + val photologId: Long = -1, + val selectedDate: String = "", + val comment: String = "", +) diff --git a/core/navigation/src/main/java/com/twix/navigation/savedstate/SafeDecode.kt b/core/navigation/src/main/java/com/twix/navigation/savedstate/SafeDecode.kt new file mode 100644 index 00000000..68c3b785 --- /dev/null +++ b/core/navigation/src/main/java/com/twix/navigation/savedstate/SafeDecode.kt @@ -0,0 +1,23 @@ +package com.twix.navigation.savedstate + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import co.touchlab.kermit.Logger +import kotlinx.serialization.json.Json + +inline fun SavedStateHandle.decodeNavArgs(key: String): T { + val raw = get(key) + + try { + val decoded = + raw?.let(Uri::decode) + ?: error("Missing nav arg: $key") + + Logger.d { "NavArgs[$key] decoded=$decoded" } + + return Json.decodeFromString(decoded) + } catch (e: Exception) { + Logger.e(e) { "NavArgs[$key] decode failed raw=$raw" } + throw e + } +} diff --git a/core/network/src/main/java/com/twix/network/model/request/photolog/model/PhotologModifyRequest.kt b/core/network/src/main/java/com/twix/network/model/request/photolog/model/PhotologModifyRequest.kt new file mode 100644 index 00000000..b839bed8 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/request/photolog/model/PhotologModifyRequest.kt @@ -0,0 +1,10 @@ +package com.twix.network.model.request.photolog.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotologModifyRequest( + @SerialName("fileName") val fileName: String, + @SerialName("comment") val comment: String, +) diff --git a/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt b/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt index 010284b5..65bfc598 100644 --- a/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt +++ b/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt @@ -1,6 +1,7 @@ package com.twix.network.service import com.twix.network.model.request.ReactionRequest +import com.twix.network.model.request.photolog.model.PhotologModifyRequest import com.twix.network.model.request.photolog.model.PhotologRequest import com.twix.network.model.response.photo.model.PhotoLogUploadUrlResponse import com.twix.network.model.response.photolog.PhotoLogsResponse @@ -10,6 +11,7 @@ import de.jensklingenberg.ktorfit.http.POST import de.jensklingenberg.ktorfit.http.PUT import de.jensklingenberg.ktorfit.http.Path import de.jensklingenberg.ktorfit.http.Query +import java.time.LocalDate interface PhotoLogService { @GET("api/v1/photologs/upload-url") @@ -24,7 +26,7 @@ interface PhotoLogService { @GET("api/v1/photologs") suspend fun fetchPhotoLogs( - @Query("targetDate") request: String, + @Query("targetDate") request: LocalDate, ): PhotoLogsResponse @PUT("api/v1/photologs/{photologId}/reaction") @@ -32,4 +34,10 @@ interface PhotoLogService { @Path("photologId") photologId: Long, @Body request: ReactionRequest, ) + + @PUT("api/v1/photologs/{photologId}") + suspend fun modifyPhotolog( + @Path("photologId") photologId: Long, + @Body request: PhotologModifyRequest, + ) } diff --git a/core/util/src/main/java/com/twix/util/bus/TaskCertificationRefreshBus.kt b/core/util/src/main/java/com/twix/util/bus/TaskCertificationRefreshBus.kt index f7a55423..578d3a57 100644 --- a/core/util/src/main/java/com/twix/util/bus/TaskCertificationRefreshBus.kt +++ b/core/util/src/main/java/com/twix/util/bus/TaskCertificationRefreshBus.kt @@ -5,13 +5,18 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow class TaskCertificationRefreshBus { + enum class Publisher { + PHOTOLOG, + EDITOR, + } + private val _events = - MutableSharedFlow( + MutableSharedFlow( replay = 0, extraBufferCapacity = 1, ) - val events: SharedFlow = _events.asSharedFlow() + val events: SharedFlow = _events.asSharedFlow() - fun notifyChanged() = _events.tryEmit(Unit) + fun notifyChanged(value: Publisher) = _events.tryEmit(value) } diff --git a/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt index e6173831..7e94b786 100644 --- a/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt +++ b/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt @@ -8,11 +8,13 @@ import com.twix.domain.repository.PhotoLogRepository import com.twix.network.execute.safeApiCall import com.twix.network.model.request.ReactionRequest import com.twix.network.model.request.photolog.mapper.toRequest +import com.twix.network.model.request.photolog.model.PhotologModifyRequest import com.twix.network.model.response.photo.mapper.toDomain import com.twix.network.model.response.photolog.mapper.toDomain import com.twix.network.service.PhotoLogService import com.twix.network.upload.PresignedUploader import com.twix.result.AppResult +import java.time.LocalDate class DefaultPhotoLogRepository( private val service: PhotoLogService, @@ -20,10 +22,10 @@ class DefaultPhotoLogRepository( ) : PhotoLogRepository { override suspend fun getUploadUrl(goalId: Long): AppResult = safeApiCall { service.getUploadUrl(goalId).toDomain() } - override suspend fun uploadPhotoLog(photologParam: PhotologParam): AppResult = + override suspend fun uploadPhotolog(photologParam: PhotologParam): AppResult = safeApiCall { service.uploadPhotoLog(photologParam.toRequest()) } - override suspend fun uploadPhotoLogImage( + override suspend fun uploadPhotologImage( goalId: Long, bytes: ByteArray, contentType: String, @@ -49,7 +51,7 @@ class DefaultPhotoLogRepository( return AppResult.Success(info.fileName) } - override suspend fun fetchPhotoLogs(targetDate: String): AppResult = + override suspend fun fetchPhotologs(targetDate: LocalDate): AppResult = safeApiCall { service.fetchPhotoLogs(targetDate).toDomain() } @@ -58,4 +60,10 @@ class DefaultPhotoLogRepository( photologId: Long, reaction: GoalReactionType, ): AppResult = safeApiCall { service.reactToPhotolog(photologId, ReactionRequest(reaction.toApi())) } + + override suspend fun modifyPhotolog( + photologId: Long, + fileName: String, + comment: String, + ): AppResult = safeApiCall { service.modifyPhotolog(photologId, PhotologModifyRequest(fileName, comment)) } } diff --git a/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt b/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt index bddd2607..063f5895 100644 --- a/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt @@ -5,22 +5,29 @@ import com.twix.domain.model.photo.PhotoLogUploadInfo import com.twix.domain.model.photo.PhotologParam import com.twix.domain.model.photolog.PhotoLogs import com.twix.result.AppResult +import java.time.LocalDate interface PhotoLogRepository { suspend fun getUploadUrl(goalId: Long): AppResult - suspend fun uploadPhotoLog(photologParam: PhotologParam): AppResult + suspend fun uploadPhotolog(photologParam: PhotologParam): AppResult - suspend fun uploadPhotoLogImage( + suspend fun uploadPhotologImage( goalId: Long, bytes: ByteArray, contentType: String, ): AppResult - suspend fun fetchPhotoLogs(targetDate: String): AppResult + suspend fun fetchPhotologs(targetDate: LocalDate): AppResult suspend fun reactToPhotolog( photologId: Long, reaction: GoalReactionType, ): AppResult + + suspend fun modifyPhotolog( + photologId: Long, + fileName: String, + comment: String, + ): AppResult } diff --git a/feature/main/src/main/java/com/twix/home/HomeScreen.kt b/feature/main/src/main/java/com/twix/home/HomeScreen.kt index 3958c234..4e4e5f1a 100644 --- a/feature/main/src/main/java/com/twix/home/HomeScreen.kt +++ b/feature/main/src/main/java/com/twix/home/HomeScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -71,7 +70,7 @@ fun HomeRoute( navigateToGoalEditor: () -> Unit, navigateToGoalManage: (LocalDate) -> Unit, navigateToSettings: () -> Unit, - navigateToCertification: (Long) -> Unit, + navigateToCertification: (Long, LocalDate) -> Unit, navigateToCertificationDetail: (Long, LocalDate, BetweenUs) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -84,7 +83,7 @@ fun HomeRoute( ActivityResultContracts.RequestPermission(), ) { granted -> if (granted) { - navigateToCertification(uiState.selectedGoalId) + navigateToCertification(uiState.selectedGoalId, uiState.selectedDate) return@rememberLauncherForActivityResult } diff --git a/feature/main/src/main/java/com/twix/main/MainScreen.kt b/feature/main/src/main/java/com/twix/main/MainScreen.kt index 119df5dc..fbf83b75 100644 --- a/feature/main/src/main/java/com/twix/main/MainScreen.kt +++ b/feature/main/src/main/java/com/twix/main/MainScreen.kt @@ -31,7 +31,7 @@ fun MainRoute( navigateToGoalEditor: () -> Unit, navigateToGoalManage: (LocalDate) -> Unit, navigateToSettings: () -> Unit, - navigateToCertification: (Long) -> Unit, + navigateToCertification: (Long, LocalDate) -> Unit, navigateToCertificationDetail: (Long, LocalDate, BetweenUs) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -57,7 +57,7 @@ private fun MainScreen( navigateToGoalEditor: () -> Unit, navigateToGoalManage: (LocalDate) -> Unit, navigateToSettings: () -> Unit, - navigateToCertification: (Long) -> Unit, + navigateToCertification: (Long, LocalDate) -> Unit, navigateToCertificationDetail: (Long, LocalDate, BetweenUs) -> Unit, ) { val calendarState by homeViewModel.calendarState.collectAsStateWithLifecycle() diff --git a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt index 2ba9c31a..92a56514 100644 --- a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt +++ b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.navigation import com.twix.main.MainRoute import com.twix.navigation.NavRoutes +import com.twix.navigation.args.DetailNavArgs import com.twix.navigation.base.NavGraphContributor object MainNavGraph : NavGraphContributor { @@ -36,24 +37,27 @@ object MainNavGraph : NavGraphContributor { launchSingleTop = true } }, - navigateToCertification = { + navigateToCertification = { goalId, date -> val destination = NavRoutes.TaskCertificationRoute.createRoute( - goalId = it, - from = NavRoutes.TaskCertificationRoute.From.HOME, + DetailNavArgs( + goalId = goalId, + from = NavRoutes.TaskCertificationRoute.From.HOME, + selectedDate = date.toString(), + ), ) navController.navigate(destination) { launchSingleTop = true } }, navigateToCertificationDetail = { goalId, date, betweenUs -> - navController.navigate( + val destination = NavRoutes.TaskCertificationDetailRoute.createRoute( goalId, date, betweenUs.name, - ), - ) { + ) + navController.navigate(destination) { launchSingleTop = true } }, diff --git a/feature/task-certification/build.gradle.kts b/feature/task-certification/build.gradle.kts index 4689af48..eb6da430 100644 --- a/feature/task-certification/build.gradle.kts +++ b/feature/task-certification/build.gradle.kts @@ -10,4 +10,5 @@ dependencies { implementation(libs.bundles.cameraX) implementation(libs.guava) + implementation(libs.kotlinx.serialization.json) } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationScreen.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationScreen.kt index 75d00aab..7f4bbfaa 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationScreen.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationScreen.kt @@ -4,20 +4,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -30,33 +22,29 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.twix.designsystem.components.comment.CIRCLE_SIZE -import com.twix.designsystem.components.comment.CommentBox +import com.twix.designsystem.R +import com.twix.designsystem.components.comment.CommentAnchorFrame 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.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.R -import com.twix.task_certification.certification.camera.Camera import com.twix.task_certification.certification.component.CameraControlBar import com.twix.task_certification.certification.component.CameraPreviewBox import com.twix.task_certification.certification.component.CommentErrorText import com.twix.task_certification.certification.component.TaskCertificationTopBar -import com.twix.task_certification.certification.model.CameraPreview -import com.twix.task_certification.certification.model.TaskCertificationIntent -import com.twix.task_certification.certification.model.TaskCertificationSideEffect -import com.twix.task_certification.certification.model.TaskCertificationUiState +import com.twix.task_certification.certification.contract.TaskCertificationIntent +import com.twix.task_certification.certification.contract.TaskCertificationSideEffect +import com.twix.task_certification.certification.contract.TaskCertificationUiState +import com.twix.task_certification.certification.model.camera.Camera +import com.twix.task_certification.certification.model.camera.CameraPreview import com.twix.ui.base.ObserveAsEvents import com.twix.ui.extension.noRippleClickable import kotlinx.coroutines.launch @@ -69,6 +57,7 @@ fun TaskCertificationRoute( camera: Camera = koinInject(), viewModel: TaskCertificationViewModel = koinViewModel(), navigateToBack: () -> Unit, + navigateToDetail: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val cameraPreview by camera.surfaceRequests.collectAsStateWithLifecycle() @@ -106,8 +95,8 @@ fun TaskCertificationRoute( ), ) } - - TaskCertificationSideEffect.NavigateToDetail -> navigateToBack() + TaskCertificationSideEffect.NavigateToBack -> navigateToBack() + TaskCertificationSideEffect.NavigateToDetail -> navigateToDetail() } } @@ -165,27 +154,7 @@ private fun TaskCertificationScreen( onFocusChanged: (Boolean) -> Unit, ) { val focusManager = LocalFocusManager.current - var previewBoxBottom by remember { mutableFloatStateOf(0f) } - val density = LocalDensity.current - val imeBottom = WindowInsets.ime.getBottom(density) - - /** - * Comment Circle UI의 높이 - * */ - val commentBoxHeight = with(density) { CIRCLE_SIZE.toPx() } - - /** - * [기본 위치 설정] - * 프리뷰 박스 하단(previewBoxBottom)을 기준으로 배치 - * circlePx * 2 만큼 위로 올리고 패딩(+20f) - */ - val defaultY = previewBoxBottom - (commentBoxHeight * 2) + 20f - - /** - * 키보드가 올라왔을 때 CommentBox와 키보드 사이의 최소 간격 - */ - val keyboardPadding = 60f Box( modifier = @@ -194,11 +163,7 @@ private fun TaskCertificationScreen( .background(GrayColor.C500) .noRippleClickable { focusManager.clearFocus() }, ) { - Column( - Modifier - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { TaskCertificationTopBar(onClickClose = onClickClose) Spacer(modifier = Modifier.height(24.26.dp)) @@ -238,64 +203,12 @@ private fun TaskCertificationScreen( ) } - /** - * 프리뷰 박스의 하단 좌표(previewBoxBottom)가 측정되었을 때만 렌더링 시작 - * */ - if (previewBoxBottom != 0f) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - /** - * 화면의 전체 높이와 키보드 상단 경계선 좌표 계산 - * */ - val screenHeight = constraints.maxHeight.toFloat() - val keyboardTop = screenHeight - imeBottom - - /** - * Dimmed 배경 레이어 - * 키보드가 올라왔을 때만 나타나도록 하며, 클릭 시 포커스를 해제 - */ - AnimatedVisibility( - visible = uiState.commentUiModel.isFocused, - enter = fadeIn(), - exit = fadeOut(), - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(DimmedColor.D070) - .noRippleClickable { focusManager.clearFocus() }, - ) - } - - CommentBox( - uiModel = uiState.commentUiModel, - onCommentChanged = onCommentChanged, - onFocusChanged = onFocusChanged, - modifier = - Modifier - .fillMaxWidth() - .offset { - IntOffset( - x = 0, - y = - if (imeBottom > 0 && (defaultY + commentBoxHeight) > keyboardTop) { - /** - * 키보드가 활성화되었고(imeBottom > 0), - * 기존 위치(defaultY)가 키보드에 가려질 상황일 때만 실행됩니다. - * imeBottom은 키보드가 닫히기 시작하면 실시간으로 줄어들어 0에 도달합니다. - * 이때 if (imeBottom > 0) 조건이 false로 바뀌면서 즉시 else 문인 defaultY 위치로 점프하게 되는데, - * 만약 시스템의 WindowInsets 애니메이션이 끝나기 전이나 레이아웃 재계산 중에 일시적으로 기준 좌표를 잃어 - * CommentBox가 화면 최하단을 갔다 원래 위치로 돌아오는 "튀는" 현상이 발생합니다. - */ - (screenHeight - imeBottom - commentBoxHeight - keyboardPadding).toInt() - } else { - defaultY.toInt() - }, - ) - }, - ) - } - } + CommentAnchorFrame( + uiModel = uiState.comment, + anchorBottom = previewBoxBottom, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + ) } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationViewModel.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationViewModel.kt index 8730d13c..86c033bf 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationViewModel.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/TaskCertificationViewModel.kt @@ -3,15 +3,17 @@ package com.twix.task_certification.certification import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.twix.designsystem.R import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.model.photo.PhotologParam import com.twix.domain.repository.PhotoLogRepository import com.twix.navigation.NavRoutes -import com.twix.task_certification.R +import com.twix.navigation.args.DetailNavArgs +import com.twix.navigation.savedstate.decodeNavArgs +import com.twix.task_certification.certification.contract.TaskCertificationIntent +import com.twix.task_certification.certification.contract.TaskCertificationSideEffect +import com.twix.task_certification.certification.contract.TaskCertificationUiState import com.twix.task_certification.certification.model.CaptureStatus -import com.twix.task_certification.certification.model.TaskCertificationIntent -import com.twix.task_certification.certification.model.TaskCertificationSideEffect -import com.twix.task_certification.certification.model.TaskCertificationUiState import com.twix.ui.base.BaseViewModel import com.twix.ui.image.ImageGenerator import com.twix.util.bus.GoalRefreshBus @@ -25,19 +27,20 @@ import java.time.LocalDate class TaskCertificationViewModel( private val imageGenerator: ImageGenerator, private val photologRepository: PhotoLogRepository, - private val taskCertificationRefreshBus: TaskCertificationRefreshBus, + private val detailRefreshBus: TaskCertificationRefreshBus, private val goalRefreshBus: GoalRefreshBus, - saveStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle, ) : BaseViewModel( TaskCertificationUiState(), ) { - private val goalId: Long = - saveStateHandle[NavRoutes.TaskCertificationRoute.ARG_GOAL_ID] - ?: error(GOAL_ID_NOT_FOUND) + private val navArgs: DetailNavArgs = + savedStateHandle.decodeNavArgs(NavRoutes.TaskCertificationRoute.ARG_DATA) - private val from: String = - saveStateHandle[NavRoutes.TaskCertificationRoute.ARG_FROM] - ?: error(FROM_NOT_FOUND) + init { + if (navArgs.from == NavRoutes.TaskCertificationRoute.From.EDITOR) { + reduceComment(navArgs.comment) + } + } override suspend fun handleIntent(intent: TaskCertificationIntent) { when (intent) { @@ -93,7 +96,7 @@ class TaskCertificationViewModel( private fun handleUploadIntent() { val capture = currentState.capture as? CaptureStatus.Captured ?: return - if (!currentState.commentUiModel.canUpload) { + if (!currentState.comment.canUpload) { showValidationError() return } @@ -113,7 +116,7 @@ class TaskCertificationViewModel( private fun showValidationError() { viewModelScope.launch { - if (!currentState.commentUiModel.canUpload) { + if (!currentState.comment.canUpload) { reduce { showCommentError() } delay(ERROR_DISPLAY_DURATION_MS) reduce { hideCommentError() } @@ -124,40 +127,73 @@ class TaskCertificationViewModel( private fun upload(image: ByteArray) { launchResult( block = { - photologRepository.uploadPhotoLogImage( - goalId = goalId, + photologRepository.uploadPhotologImage( + goalId = navArgs.goalId, bytes = image, contentType = "image/jpeg", ) }, - onSuccess = { fileName -> uploadPhotoLog(fileName) }, + onSuccess = { fileName -> + when (navArgs.from) { + NavRoutes.TaskCertificationRoute.From.DETAIL, + NavRoutes.TaskCertificationRoute.From.HOME, + -> uploadPhotolog(fileName) + + NavRoutes.TaskCertificationRoute.From.EDITOR -> modifyPhotolog(fileName) + } + }, onError = { showToast(R.string.task_certification_upload_fail, ToastType.ERROR) }, ) } - private fun uploadPhotoLog(fileName: String) { + private fun uploadPhotolog(fileName: String) { launchResult( block = { - photologRepository.uploadPhotoLog( + photologRepository.uploadPhotolog( PhotologParam( - goalId = goalId, + goalId = navArgs.goalId, fileName = fileName, - comment = currentState.commentUiModel.comment, - verificationDate = LocalDate.now(), + comment = currentState.comment.value, + verificationDate = LocalDate.parse(navArgs.selectedDate), ), ) }, + onSuccess = { handleUploadPhotologSuccess() }, + onError = { + showToast(R.string.task_certification_upload_fail, ToastType.ERROR) + }, + ) + } + + private fun handleUploadPhotologSuccess() { + when (navArgs.from) { + NavRoutes.TaskCertificationRoute.From.HOME -> + goalRefreshBus.notifyGoalListChanged() + NavRoutes.TaskCertificationRoute.From.DETAIL -> + detailRefreshBus.notifyChanged(TaskCertificationRefreshBus.Publisher.PHOTOLOG) + NavRoutes.TaskCertificationRoute.From.EDITOR -> Unit + } + tryEmitSideEffect(TaskCertificationSideEffect.NavigateToBack) + } + + private fun modifyPhotolog(fileName: String) { + launchResult( + block = { + photologRepository.modifyPhotolog( + photologId = navArgs.photologId, + fileName = fileName, + comment = currentState.comment.value, + ) + }, onSuccess = { - when (NavRoutes.TaskCertificationRoute.From.valueOf(from)) { - NavRoutes.TaskCertificationRoute.From.DETAIL -> taskCertificationRefreshBus.notifyChanged() - NavRoutes.TaskCertificationRoute.From.HOME -> goalRefreshBus.notifyGoalListChanged() - } + detailRefreshBus.notifyChanged(TaskCertificationRefreshBus.Publisher.PHOTOLOG) tryEmitSideEffect(TaskCertificationSideEffect.NavigateToDetail) }, onError = { showToast(R.string.task_certification_upload_fail, ToastType.ERROR) + showToast(R.string.task_certification_modify_fail, ToastType.ERROR) }, ) } @@ -173,7 +209,5 @@ class TaskCertificationViewModel( companion object { private const val ERROR_DISPLAY_DURATION_MS = 1500L - private const val GOAL_ID_NOT_FOUND = "Goal Id Argument Not Found" - private const val FROM_NOT_FOUND = "From Argument Not Found" } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraControlBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraControlBar.kt index d76e1e8a..c8020dc3 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraControlBar.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraControlBar.kt @@ -25,12 +25,12 @@ 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.R 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.certification.model.CaptureStatus import com.twix.ui.extension.noRippleClickable diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraPreviewBox.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraPreviewBox.kt index 637b6dfc..d97d5d57 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraPreviewBox.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CameraPreviewBox.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.twix.designsystem.R import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme -import com.twix.task_certification.R -import com.twix.task_certification.certification.model.CameraPreview import com.twix.task_certification.certification.model.CaptureStatus import com.twix.task_certification.certification.model.TorchStatus +import com.twix.task_certification.certification.model.camera.CameraPreview import com.twix.ui.extension.noRippleClickable @Composable diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CommentErrorText.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CommentErrorText.kt index 685a016b..51bf8783 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CommentErrorText.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/CommentErrorText.kt @@ -12,12 +12,12 @@ 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.R 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) { diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/TaskCertificationTopBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/TaskCertificationTopBar.kt index 87309324..e5b962d5 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/TaskCertificationTopBar.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/component/TaskCertificationTopBar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -12,8 +13,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.twix.designsystem.R import com.twix.designsystem.theme.GrayColor -import com.twix.task_certification.R import com.twix.ui.extension.noRippleClickable @Composable @@ -25,6 +26,7 @@ internal fun TaskCertificationTopBar( modifier = modifier .fillMaxWidth() + .height(72.dp) .background(color = GrayColor.C500), ) { Image( diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationIntent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationIntent.kt similarity index 94% rename from feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationIntent.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationIntent.kt index 23049957..cbf40808 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationIntent.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationIntent.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.certification.model +package com.twix.task_certification.certification.contract import android.net.Uri import com.twix.ui.base.Intent diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationSideEffect.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationSideEffect.kt similarity index 73% rename from feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationSideEffect.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationSideEffect.kt index 96cd4380..1d2b631f 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationSideEffect.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationSideEffect.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.certification.model +package com.twix.task_certification.certification.contract import com.twix.designsystem.components.toast.model.ToastType import com.twix.ui.base.SideEffect @@ -9,5 +9,7 @@ sealed interface TaskCertificationSideEffect : SideEffect { val type: ToastType, ) : TaskCertificationSideEffect + data object NavigateToBack : TaskCertificationSideEffect + data object NavigateToDetail : TaskCertificationSideEffect } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationUiState.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationUiState.kt similarity index 71% rename from feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationUiState.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationUiState.kt index 7283fcc4..2e523cbd 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/TaskCertificationUiState.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/contract/TaskCertificationUiState.kt @@ -1,9 +1,12 @@ -package com.twix.task_certification.certification.model +package com.twix.task_certification.certification.contract import android.net.Uri import androidx.camera.core.CameraSelector import androidx.compose.runtime.Immutable import com.twix.designsystem.components.comment.model.CommentUiModel +import com.twix.task_certification.certification.model.CaptureStatus +import com.twix.task_certification.certification.model.TorchStatus +import com.twix.task_certification.certification.model.camera.CameraPreview import com.twix.ui.base.State @Immutable @@ -12,11 +15,11 @@ data class TaskCertificationUiState( val torch: TorchStatus = TorchStatus.Off, val lens: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, val preview: CameraPreview? = null, - val commentUiModel: CommentUiModel = CommentUiModel(), + val comment: CommentUiModel = CommentUiModel(), val showCommentError: Boolean = false, ) : State { val hasMaxCommentLength: Boolean - get() = commentUiModel.hasMaxCommentLength + get() = comment.hasMaxCommentLength val showTorch: Boolean get() = capture is CaptureStatus.NotCaptured && lens == CameraSelector.DEFAULT_BACK_CAMERA @@ -35,7 +38,7 @@ data class TaskCertificationUiState( } fun toggleTorch(): TaskCertificationUiState { - val newFlashMode = TorchStatus.toggle(torch) + val newFlashMode = TorchStatus.Companion.toggle(torch) return copy(torch = newFlashMode) } @@ -47,9 +50,9 @@ data class TaskCertificationUiState( fun removePicture(): TaskCertificationUiState = copy(capture = CaptureStatus.NotCaptured) - fun updateComment(comment: String) = copy(commentUiModel = commentUiModel.updateComment(comment)) + fun updateComment(newComment: String) = copy(comment = comment.updateComment(newComment)) - fun updateCommentFocus(isFocused: Boolean) = copy(commentUiModel = commentUiModel.updateFocus(isFocused)) + fun updateCommentFocus(isFocused: Boolean) = copy(comment = comment.updateFocus(isFocused)) fun showCommentError() = copy(showCommentError = true) diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/camera/Camera.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/Camera.kt similarity index 79% rename from feature/task-certification/src/main/java/com/twix/task_certification/certification/camera/Camera.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/Camera.kt index 51efc24a..bfd49e10 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/camera/Camera.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/Camera.kt @@ -1,9 +1,8 @@ -package com.twix.task_certification.certification.camera +package com.twix.task_certification.certification.model.camera import android.net.Uri import androidx.camera.core.CameraSelector import androidx.lifecycle.LifecycleOwner -import com.twix.task_certification.certification.model.CameraPreview import com.twix.task_certification.certification.model.TorchStatus import kotlinx.coroutines.flow.StateFlow diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/CameraPreview.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/CameraPreview.kt similarity index 71% rename from feature/task-certification/src/main/java/com/twix/task_certification/certification/model/CameraPreview.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/CameraPreview.kt index 17bea7a3..eca40459 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/CameraPreview.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/CameraPreview.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.certification.model +package com.twix.task_certification.certification.model.camera import androidx.camera.core.SurfaceRequest import androidx.compose.runtime.Immutable diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/certification/camera/CaptureCamera.kt b/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/CaptureCamera.kt similarity index 97% rename from feature/task-certification/src/main/java/com/twix/task_certification/certification/camera/CaptureCamera.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/CaptureCamera.kt index 058f4bde..56f2a710 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/certification/camera/CaptureCamera.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/certification/model/camera/CaptureCamera.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.certification.camera +package com.twix.task_certification.certification.model.camera import android.content.ContentValues import android.content.Context @@ -15,7 +15,6 @@ import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner -import com.twix.task_certification.certification.model.CameraPreview import com.twix.task_certification.certification.model.TorchStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetail.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetail.kt index 9431ceab..268d8ccf 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetail.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetail.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.twix.designsystem.R import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData import com.twix.designsystem.components.toast.model.ToastType @@ -28,12 +29,12 @@ import com.twix.designsystem.extension.showCameraPermissionToastWithNavigateToSe import com.twix.designsystem.theme.CommonColor import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.GoalReactionType -import com.twix.task_certification.detail.component.ReactionContent import com.twix.task_certification.detail.component.TaskCertificationCardContent import com.twix.task_certification.detail.component.TaskCertificationDetailTopBar -import com.twix.task_certification.detail.model.TaskCertificationDetailIntent -import com.twix.task_certification.detail.model.TaskCertificationDetailSideEffect -import com.twix.task_certification.detail.model.TaskCertificationDetailUiState +import com.twix.task_certification.detail.component.reaction.ReactionContent +import com.twix.task_certification.detail.contract.TaskCertificationDetailIntent +import com.twix.task_certification.detail.contract.TaskCertificationDetailSideEffect +import com.twix.task_certification.detail.contract.TaskCertificationDetailUiState import com.twix.task_certification.detail.preview.TaskCertificationDetailPreviewProvider import com.twix.ui.base.ObserveAsEvents import com.twix.ui.extension.findActivity @@ -41,13 +42,13 @@ import com.twix.ui.extension.hasCameraPermission import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject -import com.twix.designsystem.R as DesR +import java.time.LocalDate @Composable fun TaskCertificationDetailRoute( navigateToBack: () -> Unit, - navigateToUpload: (Long) -> Unit, - navigateToEditor: () -> Unit, + navigateToCertification: (Long, LocalDate) -> Unit, + navigateToEditor: (TaskCertificationDetailUiState) -> Unit, toastManager: ToastManager = koinInject(), viewModel: TaskCertificationDetailViewModel = koinViewModel(), ) { @@ -72,7 +73,7 @@ fun TaskCertificationDetailRoute( ) { granted -> if (granted) { - navigateToUpload(uiState.goalId) + navigateToCertification(uiState.goalId, uiState.selectedDate) return@rememberLauncherForActivityResult } val activity = currentContext.findActivity() ?: return@rememberLauncherForActivityResult @@ -88,7 +89,7 @@ fun TaskCertificationDetailRoute( toastManager.show( ToastData( currentContext.getString( - DesR.string.toast_camera_permission_request, + R.string.toast_camera_permission_request, ), ToastType.ERROR, ), @@ -100,11 +101,11 @@ fun TaskCertificationDetailRoute( TaskCertificationDetailScreen( uiState = uiState, onBack = navigateToBack, - onClickModify = { navigateToEditor() }, + onClickModify = { navigateToEditor(uiState) }, onClickReaction = { viewModel.dispatch(TaskCertificationDetailIntent.Reaction(it)) }, onClickUpload = { if (currentContext.hasCameraPermission()) { - navigateToUpload(uiState.goalId) + navigateToCertification(uiState.goalId, uiState.selectedDate) } else { permissionLauncher.launch(Manifest.permission.CAMERA) } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetailViewModel.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetailViewModel.kt index 1907c32c..e3cb4d09 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetailViewModel.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/TaskCertificationDetailViewModel.kt @@ -2,16 +2,16 @@ package com.twix.task_certification.detail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.twix.designsystem.R import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.model.enums.BetweenUs import com.twix.domain.model.enums.GoalReactionType import com.twix.domain.repository.PhotoLogRepository import com.twix.navigation.NavRoutes -import com.twix.task_certification.R -import com.twix.task_certification.detail.model.TaskCertificationDetailIntent -import com.twix.task_certification.detail.model.TaskCertificationDetailSideEffect -import com.twix.task_certification.detail.model.TaskCertificationDetailUiState -import com.twix.task_certification.detail.model.toUiState +import com.twix.task_certification.detail.contract.TaskCertificationDetailIntent +import com.twix.task_certification.detail.contract.TaskCertificationDetailSideEffect +import com.twix.task_certification.detail.contract.TaskCertificationDetailUiState +import com.twix.task_certification.detail.contract.toUiState import com.twix.ui.base.BaseViewModel import com.twix.util.bus.GoalRefreshBus import com.twix.util.bus.TaskCertificationRefreshBus @@ -22,10 +22,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import java.time.LocalDate class TaskCertificationDetailViewModel( private val photologRepository: PhotoLogRepository, - private val taskCertificationRefreshBus: TaskCertificationRefreshBus, + private val detailRefreshBus: TaskCertificationRefreshBus, private val goalRefreshBus: GoalRefreshBus, savedStateHandle: SavedStateHandle, ) : BaseViewModel( @@ -35,9 +36,11 @@ class TaskCertificationDetailViewModel( savedStateHandle[NavRoutes.TaskCertificationDetailRoute.ARG_GOAL_ID] ?: error(GOAL_ID_NOT_FOUND) - private val argTargetDate: String = - savedStateHandle[NavRoutes.TaskCertificationDetailRoute.ARG_DATE] - ?: error(TARGET_DATE_NOT_FOUND) + private val argTargetDate: LocalDate = + LocalDate.parse( + savedStateHandle[NavRoutes.TaskCertificationDetailRoute.ARG_DATE] + ?: error(TARGET_DATE_NOT_FOUND), + ) private val argBetweenUs: String = savedStateHandle[NavRoutes.TaskCertificationDetailRoute.ARG_BETWEEN_US] @@ -59,8 +62,8 @@ class TaskCertificationDetailViewModel( private fun fetchPhotolog() { launchResult( - block = { photologRepository.fetchPhotoLogs(argTargetDate) }, - onSuccess = { reduce { it.toUiState(argGoalId, argBetweenUs) } }, + block = { photologRepository.fetchPhotologs(argTargetDate) }, + onSuccess = { reduce { it.toUiState(argGoalId, argBetweenUs, argTargetDate) } }, onError = { showToast(R.string.task_certification_detail_fetch_photolog_fail, ToastType.ERROR) }, @@ -98,17 +101,17 @@ class TaskCertificationDetailViewModel( } } - private suspend fun reduceReaction(reaction: GoalReactionType) { - lastReaction = currentState.partnerPhotolog?.reaction - reduce { currentState.copy(partnerPhotolog = partnerPhotolog?.updateReaction(reaction)) } - reactionFlow.emit(reaction) - } - private fun collectEventBus() { viewModelScope.launch { - taskCertificationRefreshBus.events.collect { - fetchPhotolog() - goalRefreshBus.notifyGoalListChanged() + detailRefreshBus.events.collect { publisher -> + when (publisher) { + TaskCertificationRefreshBus.Publisher.PHOTOLOG -> { + fetchPhotolog() + goalRefreshBus.notifyGoalListChanged() + } + + TaskCertificationRefreshBus.Publisher.EDITOR -> fetchPhotolog() + } } } } @@ -121,6 +124,12 @@ class TaskCertificationDetailViewModel( } } + private suspend fun reduceReaction(reaction: GoalReactionType) { + lastReaction = currentState.partnerPhotolog?.reaction + reduce { currentState.copy(partnerPhotolog = partnerPhotolog?.updateReaction(reaction)) } + reactionFlow.emit(reaction) + } + private fun reduceShownCard() { reduce { toggleBetweenUs() } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationCardContent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationCardContent.kt index 6f0c2a15..d61b2395 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationCardContent.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationCardContent.kt @@ -5,10 +5,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import com.twix.designsystem.R +import com.twix.designsystem.components.photolog.BackgroundCard +import com.twix.designsystem.components.photolog.ForegroundCard import com.twix.domain.model.enums.BetweenUs -import com.twix.task_certification.R -import com.twix.task_certification.detail.model.TaskCertificationDetailUiState -import com.twix.task_certification.detail.swipe.SwipeableCard +import com.twix.task_certification.detail.component.swipe.SwipeableCard +import com.twix.task_certification.detail.contract.TaskCertificationDetailUiState @Composable internal fun TaskCertificationCardContent( diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationDetailTopBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationDetailTopBar.kt index ee916be2..4ee17ab4 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationDetailTopBar.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/TaskCertificationDetailTopBar.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.twix.designsystem.R import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.topbar.CommonTopBar import com.twix.designsystem.theme.CommonColor @@ -21,7 +22,6 @@ import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.AppTextStyle import com.twix.ui.extension.noRippleClickable -import com.twix.designsystem.R as DesR @Composable internal fun TaskCertificationDetailTopBar( @@ -34,7 +34,7 @@ internal fun TaskCertificationDetailTopBar( title = title, left = { Image( - painter = painterResource(DesR.drawable.ic_arrow3_left), + painter = painterResource(R.drawable.ic_arrow3_left), contentDescription = "back", modifier = Modifier @@ -54,7 +54,7 @@ internal fun TaskCertificationDetailTopBar( contentAlignment = Alignment.Center, ) { AppText( - text = stringResource(DesR.string.word_modify), + text = stringResource(R.string.word_modify), style = AppTextStyle.T2, color = GrayColor.C500, ) diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionBar.kt similarity index 98% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionBar.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionBar.kt index 58d068a8..cfdcf082 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionBar.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionBar.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.reaction +package com.twix.task_certification.detail.component.reaction import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/ReactionContent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionContent.kt similarity index 86% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/component/ReactionContent.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionContent.kt index 1081c5a1..75da1cad 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/ReactionContent.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionContent.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.component +package com.twix.task_certification.detail.component.reaction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -13,9 +13,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.GoalReactionType -import com.twix.task_certification.detail.reaction.ReactionBar -import com.twix.task_certification.detail.reaction.ReactionEffect -import com.twix.task_certification.detail.reaction.ReactionUiModel @Composable internal fun ReactionContent( diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionEffect.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionEffect.kt similarity index 99% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionEffect.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionEffect.kt index de5bbba7..696eb339 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionEffect.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionEffect.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.reaction +package com.twix.task_certification.detail.component.reaction import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.LinearOutSlowInEasing diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionEffectSpec.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionEffectSpec.kt similarity index 91% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionEffectSpec.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionEffectSpec.kt index 3f1db381..455b1fdf 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionEffectSpec.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionEffectSpec.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.reaction +package com.twix.task_certification.detail.component.reaction /** * ReactionEffect 애니메이션 설정 값 묶음. diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionParticle.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionParticle.kt similarity index 87% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionParticle.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionParticle.kt index 3b4c3dc2..43b72f30 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionParticle.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionParticle.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.reaction +package com.twix.task_certification.detail.component.reaction import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionUiModel.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionUiModel.kt similarity index 92% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionUiModel.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionUiModel.kt index fd0dc409..6f68fd1a 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/reaction/ReactionUiModel.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/reaction/ReactionUiModel.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.reaction +package com.twix.task_certification.detail.component.reaction import com.twix.designsystem.R import com.twix.domain.model.enums.GoalReactionType diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/swipe/SwipeCardSpec.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/swipe/SwipeCardSpec.kt similarity index 92% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/swipe/SwipeCardSpec.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/swipe/SwipeCardSpec.kt index 7ef4a845..54a445bd 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/swipe/SwipeCardSpec.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/swipe/SwipeCardSpec.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.swipe +package com.twix.task_certification.detail.component.swipe import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/swipe/SwipeableCard.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/swipe/SwipeableCard.kt similarity index 99% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/swipe/SwipeableCard.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/component/swipe/SwipeableCard.kt index 7e42fefe..9c86d778 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/swipe/SwipeableCard.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/component/swipe/SwipeableCard.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.swipe +package com.twix.task_certification.detail.component.swipe import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailIntent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailIntent.kt similarity index 87% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailIntent.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailIntent.kt index f87884b3..650100dd 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailIntent.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailIntent.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.model +package com.twix.task_certification.detail.contract import com.twix.domain.model.enums.GoalReactionType import com.twix.ui.base.Intent diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailSideEffect.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailSideEffect.kt similarity index 84% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailSideEffect.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailSideEffect.kt index b603e2f4..202348ef 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailSideEffect.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailSideEffect.kt @@ -1,4 +1,4 @@ -package com.twix.task_certification.detail.model +package com.twix.task_certification.detail.contract import com.twix.designsystem.components.toast.model.ToastType import com.twix.ui.base.SideEffect diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailUiState.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailUiState.kt similarity index 84% rename from feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailUiState.kt rename to feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailUiState.kt index ac220851..8dd2d896 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/model/TaskCertificationDetailUiState.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/contract/TaskCertificationDetailUiState.kt @@ -1,17 +1,20 @@ -package com.twix.task_certification.detail.model +package com.twix.task_certification.detail.contract import androidx.compose.runtime.Immutable import com.twix.domain.model.enums.BetweenUs import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.photolog.PhotoLogs import com.twix.domain.model.photolog.PhotologDetail +import com.twix.navigation.args.EditorNavArgs import com.twix.ui.base.State import com.twix.util.RelativeTimeFormatter +import java.time.LocalDate @Immutable data class TaskCertificationDetailUiState( val goalId: Long = -1L, val currentShow: BetweenUs = BetweenUs.PARTNER, + val selectedDate: LocalDate = LocalDate.now(), val myNickname: String = "", val partnerNickname: String = "", val goalName: String = "", @@ -70,11 +73,22 @@ data class TaskCertificationDetailUiState( val canReaction: Boolean get() = currentShow == BetweenUs.PARTNER && isDisplayedGoalCertificated + + fun toSerializer() = + EditorNavArgs( + goalId = goalId, + nickname = myNickname, + goalName = goalName, + photologId = myPhotolog?.photologId ?: -1, + imageUrl = myPhotolog?.imageUrl ?: "", + comment = myPhotolog?.comment, + ) } fun PhotoLogs.toUiState( goalId: Long, betweenUs: String, + selectedDate: LocalDate, ): TaskCertificationDetailUiState { val currentGoalPhotolog = goals.firstOrNull { @@ -84,6 +98,7 @@ fun PhotoLogs.toUiState( return TaskCertificationDetailUiState( goalId = goalId, currentShow = BetweenUs.valueOf(betweenUs), + selectedDate = selectedDate, myNickname = myNickname, partnerNickname = partnerNickname, goalName = currentGoalPhotolog.goalName, diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/detail/preview/TaskCertificationDetailPreviewProvider.kt b/feature/task-certification/src/main/java/com/twix/task_certification/detail/preview/TaskCertificationDetailPreviewProvider.kt index a678d2cf..03eebdbf 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/detail/preview/TaskCertificationDetailPreviewProvider.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/detail/preview/TaskCertificationDetailPreviewProvider.kt @@ -5,7 +5,7 @@ import com.twix.domain.model.enums.BetweenUs import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.enums.GoalReactionType import com.twix.domain.model.photolog.PhotologDetail -import com.twix.task_certification.detail.model.TaskCertificationDetailUiState +import com.twix.task_certification.detail.contract.TaskCertificationDetailUiState class TaskCertificationDetailPreviewProvider : PreviewParameterProvider { override val values = diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt b/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt index 6d221630..fddd2a5a 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/di/TaskCertificationModule.kt @@ -3,9 +3,10 @@ package com.twix.task_certification.di import com.twix.navigation.NavRoutes import com.twix.navigation.base.NavGraphContributor import com.twix.task_certification.certification.TaskCertificationViewModel -import com.twix.task_certification.certification.camera.Camera -import com.twix.task_certification.certification.camera.CaptureCamera +import com.twix.task_certification.certification.model.camera.Camera +import com.twix.task_certification.certification.model.camera.CaptureCamera import com.twix.task_certification.detail.TaskCertificationDetailViewModel +import com.twix.task_certification.editor.TaskCertificationEditorViewModel import com.twix.task_certification.navigation.TaskCertificationGraph import org.koin.core.module.dsl.viewModelOf import org.koin.core.qualifier.named @@ -15,6 +16,7 @@ val taskCertificationModule = module { viewModelOf(::TaskCertificationDetailViewModel) viewModelOf(::TaskCertificationViewModel) + viewModelOf(::TaskCertificationEditorViewModel) factory { CaptureCamera(get()) } single(named(NavRoutes.TaskCertificationRoute.route)) { TaskCertificationGraph } } diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/TaskCertificationEditorRoute.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/TaskCertificationEditorRoute.kt new file mode 100644 index 00000000..29f3d33c --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/TaskCertificationEditorRoute.kt @@ -0,0 +1,206 @@ +package com.twix.task_certification.editor + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +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.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.twix.designsystem.R +import com.twix.designsystem.components.comment.CommentAnchorFrame +import com.twix.designsystem.components.photolog.PhotologCard +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.extension.showCameraPermissionToastWithNavigateToSettingAction +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.task_certification.editor.component.RetakeButton +import com.twix.task_certification.editor.component.TaskCertificationEditorTopBar +import com.twix.task_certification.editor.contract.TaskCertificationEditorIntent +import com.twix.task_certification.editor.contract.TaskCertificationEditorSideEffect +import com.twix.task_certification.editor.contract.TaskCertificationEditorUiState +import com.twix.ui.base.ObserveAsEvents +import com.twix.ui.extension.findActivity +import com.twix.ui.extension.hasCameraPermission +import com.twix.ui.extension.noRippleClickable +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +@Composable +fun TaskCertificationEditorRoute( + navigateToBack: () -> Unit, + navigateToCertification: (Long, Long, String) -> Unit, + toastManager: ToastManager = koinInject(), + viewModel: TaskCertificationEditorViewModel = koinViewModel(), +) { + val context = LocalContext.current + val currentContext by rememberUpdatedState(context) + val coroutineScope = rememberCoroutineScope() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ObserveAsEvents(viewModel.sideEffect) { sideEffect -> + when (sideEffect) { + is TaskCertificationEditorSideEffect.ShowToast -> + toastManager.tryShow( + ToastData( + currentContext.getString(sideEffect.message), + sideEffect.type, + ), + ) + } + } + + val permissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + + if (granted) { + navigateToCertification(uiState.goalId, uiState.photologId, uiState.comment.value) + return@rememberLauncherForActivityResult + } + val activity = currentContext.findActivity() ?: return@rememberLauncherForActivityResult + val shouldShowRationale = + ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.CAMERA, + ) + coroutineScope.launch { + if (!shouldShowRationale) { + toastManager.showCameraPermissionToastWithNavigateToSettingAction(currentContext) + } else { + toastManager.show( + ToastData( + currentContext.getString( + R.string.toast_camera_permission_request, + ), + ToastType.ERROR, + ), + ) + } + } + } + + TaskCertificationEditorScreen( + uiState = uiState, + onBack = navigateToBack, + onClickSave = { viewModel.dispatch(TaskCertificationEditorIntent.Save) }, + onFocusChanged = { viewModel.dispatch(TaskCertificationEditorIntent.CommentFocusChanged(it)) }, + onCommentChanged = { viewModel.dispatch(TaskCertificationEditorIntent.ModifyComment(it)) }, + onClickRetake = { + if (currentContext.hasCameraPermission()) { + navigateToCertification(uiState.goalId, uiState.photologId, uiState.comment.value) + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + ) +} + +@Composable +fun TaskCertificationEditorScreen( + uiState: TaskCertificationEditorUiState, + onBack: () -> Unit, + onClickSave: () -> Unit, + onCommentChanged: (String) -> Unit, + onFocusChanged: (Boolean) -> Unit, + onClickRetake: () -> Unit, +) { + val focusManager = LocalFocusManager.current + var photologBottom by remember { mutableFloatStateOf(0f) } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = CommonColor.White) + .noRippleClickable { focusManager.clearFocus() }, + ) { + Column { + TaskCertificationEditorTopBar( + title = uiState.goalName, + onBack = onBack, + onClickSave = onClickSave, + ) + + Spacer(Modifier.height(103.dp)) + + PhotologCard( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + val bottom = coordinates.boundsInParent().bottom + if (photologBottom != bottom) { + photologBottom = bottom + } + }, + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(uiState.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + + Spacer(Modifier.height(101.dp)) + + RetakeButton(onClickRetake = onClickRetake) + } + + CommentAnchorFrame( + uiModel = uiState.comment, + anchorBottom = photologBottom, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TaskCertificationEditorScreenPreview() { + TwixTheme { + TaskCertificationEditorScreen( + uiState = + TaskCertificationEditorUiState( + nickname = "페토", + goalName = "아이스크림 먹기", + ), + onBack = {}, + onClickSave = {}, + onFocusChanged = {}, + onClickRetake = {}, + onCommentChanged = {}, + ) + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/TaskCertificationEditorViewModel.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/TaskCertificationEditorViewModel.kt new file mode 100644 index 00000000..0cf58c24 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/TaskCertificationEditorViewModel.kt @@ -0,0 +1,86 @@ +package com.twix.task_certification.editor + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.twix.designsystem.R +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.domain.repository.PhotoLogRepository +import com.twix.navigation.NavRoutes +import com.twix.navigation.args.EditorNavArgs +import com.twix.navigation.savedstate.decodeNavArgs +import com.twix.result.AppResult +import com.twix.task_certification.editor.contract.TaskCertificationEditorIntent +import com.twix.task_certification.editor.contract.TaskCertificationEditorSideEffect +import com.twix.task_certification.editor.contract.TaskCertificationEditorUiState +import com.twix.task_certification.editor.contract.toUiState +import com.twix.ui.base.BaseViewModel +import com.twix.util.bus.TaskCertificationRefreshBus +import kotlinx.coroutines.launch + +class TaskCertificationEditorViewModel( + private val photologRepository: PhotoLogRepository, + private val detailRefreshBus: TaskCertificationRefreshBus, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + TaskCertificationEditorUiState(), + ) { + private val navArgs: EditorNavArgs = + savedStateHandle.decodeNavArgs(NavRoutes.TaskCertificationEditorRoute.ARG_DATA) + + init { + reduce { navArgs.toUiState() } + } + + override suspend fun handleIntent(intent: TaskCertificationEditorIntent) { + when (intent) { + is TaskCertificationEditorIntent.CommentFocusChanged -> reduceCommentFocus(intent.isFocused) + is TaskCertificationEditorIntent.ModifyComment -> reduceComment(intent.value) + TaskCertificationEditorIntent.Save -> modifyComment() + } + } + + private fun reduceCommentFocus(value: Boolean) { + reduce { copy(comment = comment.copy(isFocused = value)) } + } + + private fun reduceComment(value: String) { + reduce { copy(comment = comment.copy(value = value)) } + } + + private fun modifyComment() { + if (currentState.comment.canUpload.not()) { + showToast(R.string.comment_error_message, ToastType.ERROR) + } else if (currentState.isCommentNotChanged) { + showToast(R.string.task_certification_editor_not_modified, ToastType.ERROR) + } else { + launchResult( + block = { launchModifyComment() }, + onSuccess = { + detailRefreshBus.notifyChanged(TaskCertificationRefreshBus.Publisher.EDITOR) + showToast(R.string.task_certification_editor_modify_success, ToastType.SUCCESS) + }, + onError = { + showToast(R.string.task_certification_editor_modify_fail, ToastType.ERROR) + }, + ) + } + } + + private fun showToast( + message: Int, + type: ToastType, + ) { + viewModelScope.launch { + emitSideEffect( + TaskCertificationEditorSideEffect.ShowToast(message, type), + ) + } + } + + private suspend fun launchModifyComment(): AppResult = + photologRepository.modifyPhotolog( + currentState.photologId, + currentState.imageName, + currentState.comment.value, + ) +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/component/RetakeButton.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/component/RetakeButton.kt new file mode 100644 index 00000000..c55390cb --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/component/RetakeButton.kt @@ -0,0 +1,42 @@ +package com.twix.task_certification.editor.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +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.ui.extension.noRippleClickable + +@Composable +internal fun RetakeButton( + onClickRetake: () -> Unit, + modifier: Modifier = Modifier, +) { + AppRoundButton( + text = stringResource(R.string.task_certification_editor_retake), + textColor = GrayColor.C500, + backgroundColor = CommonColor.White, + modifier = + modifier + .fillMaxWidth() + .height(68.dp) + .padding(horizontal = 30.dp) + .noRippleClickable { onClickRetake() }, + ) +} + +@Preview +@Composable +private fun RetakeButtonPreview() { + TwixTheme { + RetakeButton(onClickRetake = {}) + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/component/TaskCertificationEditorTopBar.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/component/TaskCertificationEditorTopBar.kt new file mode 100644 index 00000000..bd3c2084 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/component/TaskCertificationEditorTopBar.kt @@ -0,0 +1,83 @@ +package com.twix.task_certification.editor.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.topbar.CommonTopBar +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.ui.extension.noRippleClickable + +@Composable +internal fun TaskCertificationEditorTopBar( + title: String, + onBack: () -> Unit, + onClickSave: () -> Unit, +) { + CommonTopBar( + title = title, + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, + right = { + Box( + modifier = + Modifier + .fillMaxSize() + .background(GrayColor.C100) + .noRippleClickable { onClickSave() }, + contentAlignment = Alignment.Center, + ) { + AppText( + text = stringResource(R.string.word_save), + style = AppTextStyle.T2, + color = GrayColor.C500, + ) + } + }, + modifier = Modifier.background(color = CommonColor.White), + ) +} + +@Preview +@Composable +fun TaskCertificationEditorTopBarPreview() { + TwixTheme { + Column { + TaskCertificationEditorTopBar( + title = "목표 인증", + onBack = {}, + onClickSave = {}, + ) + + TaskCertificationEditorTopBar( + title = "목표 인증", + onBack = {}, + onClickSave = {}, + ) + } + } +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorIntent.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorIntent.kt new file mode 100644 index 00000000..42bf9693 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorIntent.kt @@ -0,0 +1,15 @@ +package com.twix.task_certification.editor.contract + +import com.twix.ui.base.Intent + +sealed interface TaskCertificationEditorIntent : Intent { + data object Save : TaskCertificationEditorIntent + + data class CommentFocusChanged( + val isFocused: Boolean, + ) : TaskCertificationEditorIntent + + data class ModifyComment( + val value: String, + ) : TaskCertificationEditorIntent +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorSideEffect.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorSideEffect.kt new file mode 100644 index 00000000..f46c5660 --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorSideEffect.kt @@ -0,0 +1,11 @@ +package com.twix.task_certification.editor.contract + +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.ui.base.SideEffect + +sealed interface TaskCertificationEditorSideEffect : SideEffect { + data class ShowToast( + val message: Int, + val type: ToastType, + ) : TaskCertificationEditorSideEffect +} diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorUiState.kt b/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorUiState.kt new file mode 100644 index 00000000..13a217ea --- /dev/null +++ b/feature/task-certification/src/main/java/com/twix/task_certification/editor/contract/TaskCertificationEditorUiState.kt @@ -0,0 +1,38 @@ +package com.twix.task_certification.editor.contract + +import androidx.compose.runtime.Immutable +import com.twix.designsystem.components.comment.model.CommentUiModel +import com.twix.navigation.args.EditorNavArgs +import com.twix.ui.base.State + +@Immutable +data class TaskCertificationEditorUiState( + val goalId: Long = -1, + val photologId: Long = -1, + val nickname: String = "", + val goalName: String = "", + val imageUrl: String = "", + val comment: CommentUiModel = CommentUiModel(), + val originComment: String = "", +) : State { + val isCommentNotChanged: Boolean + get() = comment.value == originComment + + val imageName: String + get() = imageUrl.split(IMAGE_NAME_SEPARATOR).last() + + companion object { + private const val IMAGE_NAME_SEPARATOR = "/" + } +} + +internal fun EditorNavArgs.toUiState() = + TaskCertificationEditorUiState( + goalId = goalId, + nickname = nickname, + goalName = goalName, + photologId = photologId, + imageUrl = imageUrl, + comment = CommentUiModel(comment.orEmpty()), + originComment = comment.orEmpty(), + ) diff --git a/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt b/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt index c80db656..ba37cbd8 100644 --- a/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt +++ b/feature/task-certification/src/main/java/com/twix/task_certification/navigation/TaskCertificationGraph.kt @@ -7,9 +7,11 @@ import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navigation import com.twix.navigation.NavRoutes +import com.twix.navigation.args.DetailNavArgs import com.twix.navigation.base.NavGraphContributor import com.twix.task_certification.certification.TaskCertificationRoute import com.twix.task_certification.detail.TaskCertificationDetailRoute +import com.twix.task_certification.editor.TaskCertificationEditorRoute object TaskCertificationGraph : NavGraphContributor { override val graphRoute: NavRoutes @@ -35,22 +37,55 @@ object TaskCertificationGraph : NavGraphContributor { navArgument(NavRoutes.TaskCertificationDetailRoute.ARG_BETWEEN_US) { type = NavType.StringType }, - navArgument(NavRoutes.TaskCertificationDetailRoute.ARG_DATE) { + ), + ) { + TaskCertificationDetailRoute( + navigateToBack = navController::popBackStack, + navigateToCertification = { goalId, date -> + val destination = + NavRoutes.TaskCertificationRoute.createRoute( + DetailNavArgs( + goalId = goalId, + from = NavRoutes.TaskCertificationRoute.From.DETAIL, + selectedDate = date.toString(), + ), + ) + navController.navigate(destination) + }, + navigateToEditor = { uiState -> + val serializer = uiState.toSerializer() + navController.navigate( + NavRoutes.TaskCertificationEditorRoute.createRoute( + serializer, + ), + ) + }, + ) + } + + composable( + route = NavRoutes.TaskCertificationEditorRoute.route, + arguments = + listOf( + navArgument(NavRoutes.TaskCertificationEditorRoute.ARG_DATA) { type = NavType.StringType }, ), ) { - TaskCertificationDetailRoute( + TaskCertificationEditorRoute( navigateToBack = navController::popBackStack, - navigateToUpload = { + navigateToCertification = { goalId, photologId, comment -> val destination = NavRoutes.TaskCertificationRoute.createRoute( - goalId = it, - from = NavRoutes.TaskCertificationRoute.From.DETAIL, + DetailNavArgs( + goalId = goalId, + from = NavRoutes.TaskCertificationRoute.From.EDITOR, + photologId = photologId, + comment = comment, + ), ) navController.navigate(destination) }, - navigateToEditor = { }, ) } @@ -58,17 +93,18 @@ object TaskCertificationGraph : NavGraphContributor { route = NavRoutes.TaskCertificationRoute.route, arguments = listOf( - navArgument(NavRoutes.TaskCertificationRoute.ARG_GOAL_ID) { - type = NavType.LongType - }, - navArgument(NavRoutes.TaskCertificationRoute.ARG_FROM) { + navArgument(NavRoutes.TaskCertificationRoute.ARG_DATA) { type = NavType.StringType }, ), ) { TaskCertificationRoute( - navigateToBack = { - navController.popBackStack() + navigateToBack = navController::popBackStack, + navigateToDetail = { + navController.popBackStack( + route = NavRoutes.TaskCertificationDetailRoute.route, + inclusive = false, + ) }, ) } diff --git a/feature/task-certification/src/main/res/values/strings.xml b/feature/task-certification/src/main/res/values/strings.xml deleted file mode 100644 index 4cc8b8da..00000000 --- a/feature/task-certification/src/main/res/values/strings.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - 업로드 - 이미지 캡처에 실패했습니다. 다시 시도해 주세요. - 이미지를 불러오는 데 실패했습니다. 다시 시도해 주세요. - 인증샷 찍기 - 인증샷 등록에 실패했습니다. - 이미지 변환에 실패했습니다. - 코멘트는 5글자로 입력해주세요! - - - %s님은\n아직… - 인증샷 조회에 실패했습니다. - 찌르기 - 리액션 요청에 실패했어요. - - 인증샷 촬영을 위해서 카메라 권한이 필요해요. - 설정 -